Update
This commit is contained in:
212
app/Helpers/FirstParty/Credits/CreditsHelper.php
Normal file
212
app/Helpers/FirstParty/Credits/CreditsHelper.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
app/Helpers/FirstParty/Credits/CreditsService.php
Normal file
48
app/Helpers/FirstParty/Credits/CreditsService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/Helpers/FirstParty/Credits/UserCreditHelper.php
Normal file
67
app/Helpers/FirstParty/Credits/UserCreditHelper.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php
Normal file
73
app/Helpers/FirstParty/Purchase/CreditsPurchaseHelper.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
16
app/Models/PaymentWebhookProcessedLog.php
Normal file
16
app/Models/PaymentWebhookProcessedLog.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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
39
app/Models/UserCredit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
57
app/Models/UserCreditTransaction.php
Normal file
57
app/Models/UserCreditTransaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '$',
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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 */}
|
||||
<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>
|
||||
{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">{credits}</div>
|
||||
<div className="text-muted-foreground text-sm">available</div>
|
||||
</div>
|
||||
<div className="text-foreground text-xl font-bold">999</div>
|
||||
<div className="text-muted-foreground text-sm">available</div>
|
||||
<CoinIcon className="bg-muted absolute -top-2 -right-2 h-20 w-20 opacity-15" />
|
||||
</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);
|
||||
}}
|
||||
disabled={isRedirectingToBilling}
|
||||
>
|
||||
{isRedirectingToBilling ? <Spinner className="text-muted-foreground h-4 w-4" /> : 'Manage Subscription'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
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">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="grid">
|
||||
<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>
|
||||
{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 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">{one_time.name}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-semibold break-words">{one_time.description}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-semibold break-words">
|
||||
Approx. 250 AI captions & 250 AI backgrounds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-muted-foreground text-sm">$4.00</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{one_time.symbol}
|
||||
{one_time.amount} per pack
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<Button className="w-full flex-shrink-0 sm:w-auto">Buy</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user