hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads, attach packages, and surface localized success/error states. - Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/ PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent creation, webhooks, and the wizard CTA. - Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/ useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages, Checkout) with localized copy and experiment tracking. - Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations. - Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke test for the hero CTA while reconciling outstanding checklist items.
265 lines
9.7 KiB
PHP
265 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Log;
|
|
use App\Models\PackagePurchase;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantPackage;
|
|
use App\Models\Package;
|
|
|
|
use PaypalServerSdkLib\Models\Builders\OrderRequestBuilder;
|
|
use PaypalServerSdkLib\Models\Builders\PurchaseUnitRequestBuilder;
|
|
use PaypalServerSdkLib\Models\Builders\AmountWithBreakdownBuilder;
|
|
use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder;
|
|
use PaypalServerSdkLib\Models\CheckoutPaymentIntent;
|
|
use App\Services\PayPal\PaypalClientFactory;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
class PayPalController extends Controller
|
|
{
|
|
private $client;
|
|
private PaypalClientFactory $clientFactory;
|
|
|
|
public function __construct(PaypalClientFactory $clientFactory)
|
|
{
|
|
$this->clientFactory = $clientFactory;
|
|
$this->client = $clientFactory->make();
|
|
}
|
|
|
|
public function createOrder(Request $request)
|
|
{
|
|
$request->validate([
|
|
'package_id' => 'required|exists:packages,id',
|
|
'tenant_id' => 'nullable|exists:tenants,id',
|
|
]);
|
|
|
|
$tenant = $request->tenant_id
|
|
? Tenant::findOrFail($request->tenant_id)
|
|
: optional(Auth::user())->tenant;
|
|
|
|
if (! $tenant) {
|
|
return response()->json(['error' => 'Tenant context required for checkout.'], 422);
|
|
}
|
|
|
|
$package = Package::findOrFail($request->package_id);
|
|
|
|
$ordersController = $this->client->getOrdersController();
|
|
|
|
$body = OrderRequestBuilder::init(
|
|
CheckoutPaymentIntent::CAPTURE,
|
|
[
|
|
PurchaseUnitRequestBuilder::init(
|
|
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
|
|
->build()
|
|
)
|
|
->description('Package: ' . $package->name)
|
|
->customId(json_encode([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'type' => 'endcustomer_event'
|
|
]))
|
|
->build()
|
|
]
|
|
)
|
|
->applicationContext(
|
|
OrderApplicationContextBuilder::init()
|
|
->brandName('Fotospiel')
|
|
->landingPage('BILLING')
|
|
->build()
|
|
)
|
|
->build();
|
|
|
|
$collect = [
|
|
'body' => $body,
|
|
'prefer' => 'return=representation'
|
|
];
|
|
|
|
try {
|
|
$response = $ordersController->createOrder($collect);
|
|
|
|
if ($response->getStatusCode() === 201) {
|
|
$result = $response->getResult();
|
|
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
|
|
|
|
return response()->json([
|
|
'id' => $result->id,
|
|
'approve_url' => $approveLink,
|
|
]);
|
|
}
|
|
|
|
Log::error('PayPal order creation failed', ['response' => $response]);
|
|
return response()->json(['error' => 'Order creation failed'], 400);
|
|
} catch (\Exception $e) {
|
|
Log::error('PayPal order creation exception', ['error' => $e->getMessage()]);
|
|
return response()->json(['error' => 'Order creation failed'], 500);
|
|
}
|
|
}
|
|
|
|
public function captureOrder(Request $request)
|
|
{
|
|
$request->validate(['order_id' => 'required']);
|
|
|
|
$ordersController = $this->client->getOrdersController();
|
|
|
|
$collect = [
|
|
'id' => $request->order_id,
|
|
'prefer' => 'return=representation'
|
|
];
|
|
|
|
try {
|
|
$response = $ordersController->captureOrder($collect);
|
|
|
|
if ($response->getStatusCode() === 201) {
|
|
$result = $response->getResult();
|
|
$customId = $result->purchaseUnits[0]->customId ?? null;
|
|
|
|
if ($customId) {
|
|
$metadata = json_decode($customId, true);
|
|
$tenantId = $metadata['tenant_id'] ?? null;
|
|
$packageId = $metadata['package_id'] ?? null;
|
|
$type = $metadata['type'] ?? 'endcustomer_event';
|
|
|
|
if ($tenantId && $packageId) {
|
|
$tenant = Tenant::findOrFail($tenantId);
|
|
$package = Package::findOrFail($packageId);
|
|
|
|
PackagePurchase::create([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'provider_id' => $result->id,
|
|
'price' => $result->purchaseUnits[0]->amount->value,
|
|
'type' => $type,
|
|
'purchased_at' => now(),
|
|
'refunded' => false,
|
|
]);
|
|
TenantPackage::create([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'price' => $package->price,
|
|
'purchased_at' => now(),
|
|
'active' => true,
|
|
]);
|
|
|
|
$tenant->update(['subscription_status' => 'active']);
|
|
} else {
|
|
Log::error('Invalid metadata in PayPal custom_id', ['custom_id' => $customId]);
|
|
}
|
|
|
|
Log::info('PayPal order captured and purchase created: ' . $result->id);
|
|
}
|
|
|
|
return response()->json(['status' => 'captured', 'order' => $result]);
|
|
}
|
|
|
|
Log::error('PayPal order capture failed', ['response' => $response]);
|
|
return response()->json(['error' => 'Capture failed'], 400);
|
|
} catch (\Exception $e) {
|
|
Log::error('PayPal order capture exception', ['error' => $e->getMessage()]);
|
|
return response()->json(['error' => 'Capture failed'], 500);
|
|
}
|
|
}
|
|
|
|
public function createSubscription(Request $request)
|
|
{
|
|
$request->validate([
|
|
'package_id' => 'required|exists:packages,id',
|
|
'plan_id' => 'required|string',
|
|
'tenant_id' => 'nullable|exists:tenants,id',
|
|
]);
|
|
|
|
$tenant = $request->tenant_id
|
|
? Tenant::findOrFail($request->tenant_id)
|
|
: optional(Auth::user())->tenant;
|
|
|
|
if (! $tenant) {
|
|
return response()->json(['error' => 'Tenant context required for subscription checkout.'], 422);
|
|
}
|
|
$package = Package::findOrFail($request->package_id);
|
|
|
|
$ordersController = $this->client->getOrdersController();
|
|
|
|
$storedPaymentSource = new \PaypalServerSdkLib\Models\StoredPaymentSource(
|
|
'CUSTOMER',
|
|
'RECURRING'
|
|
);
|
|
$storedPaymentSource->setUsage('FIRST');
|
|
|
|
$paymentSource = new \PaypalServerSdkLib\Models\PaymentSource();
|
|
$paymentSource->storedPaymentSource = $storedPaymentSource;
|
|
|
|
$body = OrderRequestBuilder::init(
|
|
CheckoutPaymentIntent::CAPTURE,
|
|
[
|
|
PurchaseUnitRequestBuilder::init(
|
|
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
|
|
->build()
|
|
)
|
|
->description('Subscription Package: ' . $package->name)
|
|
->customId(json_encode([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'type' => 'reseller_subscription',
|
|
'plan_id' => $request->plan_id
|
|
]))
|
|
->build()
|
|
]
|
|
)
|
|
->paymentSource($paymentSource)
|
|
->applicationContext(
|
|
OrderApplicationContextBuilder::init()
|
|
->brandName('Fotospiel')
|
|
->landingPage('BILLING')
|
|
->build()
|
|
)
|
|
->build();
|
|
|
|
$collect = [
|
|
'body' => $body,
|
|
'prefer' => 'return=representation'
|
|
];
|
|
|
|
try {
|
|
$response = $ordersController->createOrder($collect);
|
|
|
|
if ($response->getStatusCode() === 201) {
|
|
$result = $response->getResult();
|
|
$orderId = $result->id;
|
|
|
|
// Initial purchase record for subscription setup
|
|
TenantPackage::create([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'price' => $package->price,
|
|
'purchased_at' => now(),
|
|
'expires_at' => now()->addYear(), // Assuming annual subscription
|
|
'active' => true,
|
|
]);
|
|
|
|
PackagePurchase::create([
|
|
'tenant_id' => $tenant->id,
|
|
'package_id' => $package->id,
|
|
'provider_id' => $orderId . '_sub_' . $request->plan_id, // Combine for uniqueness
|
|
'price' => $package->price,
|
|
'type' => 'reseller_subscription',
|
|
'purchased_at' => now(),
|
|
]);
|
|
|
|
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
|
|
|
|
return response()->json([
|
|
'order_id' => $orderId,
|
|
'approve_url' => $approveLink,
|
|
]);
|
|
}
|
|
|
|
Log::error('PayPal subscription order creation failed', ['response' => $response]);
|
|
return response()->json(['error' => 'Subscription order creation failed'], 400);
|
|
} catch (\Exception $e) {
|
|
Log::error('PayPal subscription order creation exception', ['error' => $e->getMessage()]);
|
|
return response()->json(['error' => 'Subscription order creation failed'], 500);
|
|
}
|
|
}
|
|
}
|