feat(packages): implement package-based business model
This commit is contained in:
116
app/Http/Controllers/Api/PackageController.php
Normal file
116
app/Http/Controllers/Api/PackageController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'endcustomer');
|
||||
$packages = Package::where('type', $type)
|
||||
->orderBy('price')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$package->features = json_decode($package->features ?? '[]', true);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $packages,
|
||||
'message' => "Packages for type '{$type}' loaded successfully.",
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchase(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer_event,reseller_subscription',
|
||||
'payment_method' => 'required|in:stripe,paypal',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (!$tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
if ($package->price == 0) {
|
||||
// Free package - direct assignment
|
||||
return $this->handleFreePurchase($request, $package, $tenant);
|
||||
}
|
||||
|
||||
// Paid purchase
|
||||
return $this->handlePaidPurchase($request, $package, $tenant);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
DB::transaction(function () use ($request, $package, $tenant) {
|
||||
$purchaseData = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $request->event_id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => $request->type,
|
||||
'metadata' => json_encode([
|
||||
'note' => 'Free package assigned',
|
||||
'ip' => $request->ip(),
|
||||
]),
|
||||
];
|
||||
|
||||
PackagePurchase::create($purchaseData);
|
||||
|
||||
if ($request->event_id) {
|
||||
// Assign to event
|
||||
\App\Models\EventPackage::create([
|
||||
'event_id' => $request->event_id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
// Reseller subscription
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Free package assigned successfully.',
|
||||
'purchase' => ['package' => $package->name, 'type' => $request->type],
|
||||
], 201);
|
||||
}
|
||||
|
||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
$type = $request->type;
|
||||
|
||||
if ($type === 'reseller_subscription') {
|
||||
$response = (new StripeController())->createSubscription($request);
|
||||
return $response;
|
||||
} else {
|
||||
$response = (new StripeController())->createPaymentIntent($request);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Api/StripeController.php
Normal file
100
app/Http/Controllers/Api/StripeController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
use Stripe\Subscription;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
use Stripe\Webhook;
|
||||
|
||||
class StripeController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
}
|
||||
|
||||
public function createPaymentIntent(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer_event,reseller_subscription',
|
||||
'tenant_id' => 'nullable|exists:tenants,id', // For reseller
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
]);
|
||||
|
||||
$package = \App\Models\Package::findOrFail($request->package_id);
|
||||
|
||||
$amount = $package->price * 100; // Cents
|
||||
|
||||
$metadata = [
|
||||
'package_id' => $package->id,
|
||||
'type' => $request->type,
|
||||
];
|
||||
|
||||
if ($request->tenant_id) {
|
||||
$metadata['tenant_id'] = $request->tenant_id;
|
||||
}
|
||||
|
||||
if ($request->event_id) {
|
||||
$metadata['event_id'] = $request->event_id;
|
||||
}
|
||||
|
||||
$intent = PaymentIntent::create([
|
||||
'amount' => $amount,
|
||||
'currency' => 'eur',
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'client_secret' => $intent->client_secret,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createSubscription(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$package = \App\Models\Package::findOrFail($request->package_id);
|
||||
$tenant = \App\Models\Tenant::findOrFail($request->tenant_id);
|
||||
|
||||
// Assume customer exists or create
|
||||
$customer = $tenant->stripe_customer_id ? \Stripe\Customer::retrieve($tenant->stripe_customer_id) : \Stripe\Customer::create([
|
||||
'email' => $tenant->email,
|
||||
'metadata' => ['tenant_id' => $tenant->id],
|
||||
]);
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'customer' => $customer->id,
|
||||
'items' => [[
|
||||
'price' => $package->stripe_price_id, // Assume price ID set in package
|
||||
]],
|
||||
'metadata' => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
]);
|
||||
|
||||
// Create initial tenant package
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'stripe_subscription_id' => $subscription->id,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'subscription_id' => $subscription->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
136
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Webhook;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function handleWebhook(Request $request)
|
||||
{
|
||||
$payload = $request->getContent();
|
||||
$sigHeader = $request->header('Stripe-Signature');
|
||||
$endpointSecret = config('services.stripe.webhook_secret');
|
||||
|
||||
try {
|
||||
$event = Webhook::constructEvent(
|
||||
$payload, $sigHeader, $endpointSecret
|
||||
);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch ($event['type']) {
|
||||
case 'payment_intent.succeeded':
|
||||
$paymentIntent = $event['data']['object'];
|
||||
$this->handlePaymentIntentSucceeded($paymentIntent);
|
||||
break;
|
||||
|
||||
case 'invoice.paid':
|
||||
$invoice = $event['data']['object'];
|
||||
$this->handleInvoicePaid($invoice);
|
||||
break;
|
||||
|
||||
default:
|
||||
\Log::info('Unhandled Stripe event', ['type' => $event['type']]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success'], 200);
|
||||
}
|
||||
|
||||
private function handlePaymentIntentSucceeded(array $paymentIntent)
|
||||
{
|
||||
$metadata = $paymentIntent['metadata'];
|
||||
$packageId = $metadata['package_id'];
|
||||
$type = $metadata['type'];
|
||||
|
||||
\DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
|
||||
// Create purchase record
|
||||
$purchase = PackagePurchase::create([
|
||||
'package_id' => $packageId,
|
||||
'type' => $type,
|
||||
'provider_id' => 'stripe',
|
||||
'transaction_id' => $paymentIntent['id'],
|
||||
'purchased_price' => $paymentIntent['amount_received'] / 100,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if ($type === 'endcustomer_event') {
|
||||
$eventId = $metadata['event_id'];
|
||||
EventPackage::create([
|
||||
'event_id' => $eventId,
|
||||
'package_id' => $packageId,
|
||||
'package_purchase_id' => $purchase->id,
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'expires_at' => now()->addDays(30), // Default, or from package
|
||||
]);
|
||||
} elseif ($type === 'reseller_subscription') {
|
||||
$tenantId = $metadata['tenant_id'];
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'package_id' => $packageId,
|
||||
'package_purchase_id' => $purchase->id,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function handleInvoicePaid(array $invoice)
|
||||
{
|
||||
$subscription = $invoice['subscription'];
|
||||
$metadata = $subscription['metadata'] ?? [];
|
||||
|
||||
if (isset($metadata['tenant_id'])) {
|
||||
$tenantId = $metadata['tenant_id'];
|
||||
$packageId = $metadata['package_id'];
|
||||
|
||||
// Renew or create tenant package
|
||||
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
|
||||
->where('package_id', $packageId)
|
||||
->where('stripe_subscription_id', $subscription)
|
||||
->first();
|
||||
|
||||
if ($tenantPackage) {
|
||||
$tenantPackage->update([
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
} else {
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'package_id' => $packageId,
|
||||
'stripe_subscription_id' => $subscription,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Create purchase record
|
||||
PackagePurchase::create([
|
||||
'package_id' => $packageId,
|
||||
'type' => 'reseller_subscription',
|
||||
'provider_id' => 'stripe',
|
||||
'transaction_id' => $invoice['id'],
|
||||
'purchased_price' => $invoice['amount_paid'] / 100,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,15 +53,18 @@ class EventController extends Controller
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
if (!$tenant->canCreateEvent()) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient event credits. Please purchase more credits.',
|
||||
'error' => 'No available package for creating events. Please purchase a package.',
|
||||
], 402);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$packageId = $validated['package_id'] ?? 1; // Default to Free package ID 1
|
||||
unset($validated['package_id']);
|
||||
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
@@ -116,24 +119,43 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
$note = sprintf('Event create: %s', $event->slug);
|
||||
if (! $tenant->decrementCredits(1, 'event_create', $note, null)) {
|
||||
throw new \RuntimeException('Unable to deduct credits');
|
||||
// Create EventPackage and PackagePurchase for Free package
|
||||
$package = \App\Models\Package::findOrFail($packageId);
|
||||
$eventPackage = \App\Models\EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
||||
]);
|
||||
|
||||
if ($tenant->activeResellerPackage) {
|
||||
$tenant->incrementUsedEvents();
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant']);
|
||||
$event->load(['eventType', 'tenant', 'eventPackage.package']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
'data' => new EventResource($event),
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
||||
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
||||
36
app/Http/Controllers/Api/TenantPackageController.php
Normal file
36
app/Http/Controllers/Api/TenantPackageController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TenantPackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (!$tenant) {
|
||||
return response()->json(['error' => 'Tenant not found.'], 404);
|
||||
}
|
||||
|
||||
$packages = TenantPackage::where('tenant_id', $tenant->id)
|
||||
->with('package')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$package->remaining_events = $package->package->max_events_per_year - $package->used_events;
|
||||
$package->package_limits = $package->package->getAttributes(); // Or custom accessor for limits
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $packages,
|
||||
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null,
|
||||
'message' => 'Tenant packages loaded successfully.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user