Demo plan + Payment integrations (Stripe/PayPal/Bank)

Models & migrations:
- platform_settings table (key/value JSON store + Cache::remember 5min)
- plans: is_demo bool + trial_days int
- companies: is_demo bool

Plans:
- Demo plan seeded (is_demo=true, is_public=false, all features, 14 trial days)
- Trial 14-day plan seeded (is_public=true, basic features)
- Plan form: is_demo toggle + trial_days field
- Plan table: badge 🎬 Demo / 🎁 N zile trial

Central panel:
- PaymentSettings page (heroicon-credit-card, sort 90)
  Form sections: General, Date legale, Stripe, PayPal, Transfer bancar
  Each gateway collapsible, fields hidden until enabled toggle
  Saves to platform_settings keyed by `payments.{gateway}`
- CompanyResource: is_demo toggle + table description

Payment flow (PaymentController):
- GET  /billing                 — tenant invoices list with Pay button
- POST /pay/{sub}               — start checkout (stripe/paypal/bank)
- GET  /pay/{sub}/{success,cancel}
- POST /payments/stripe/webhook — mark paid + extend company.active_until
- POST /payments/paypal/webhook — same

Views:
- site/billing.blade.php       — invoices list with payment modal (3 methods)
- site/bank-instructions       — IBAN/BIC/reference for manual transfer
- site/checkout-stub           — placeholder until composer require stripe-php
- site/payment-{success,cancel}

Tenant panel:
- userMenuItems → "Facturile mele" link to /billing
This commit is contained in:
2026-05-08 05:55:30 +00:00
parent d1a18848d3
commit 827bf12d89
16 changed files with 904 additions and 3 deletions
@@ -0,0 +1,221 @@
<?php
namespace App\Filament\Central\Pages;
use App\Models\Central\PlatformSetting;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
class PaymentSettings extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $navigationLabel = 'Setări plăți';
protected static ?string $title = 'Integrări de plată';
protected static ?int $navigationSort = 90;
protected string $view = 'filament.central.pages.payment-settings';
public ?array $data = [];
public function mount(): void
{
$s = PlatformSetting::many([
'payments.stripe', 'payments.paypal', 'payments.bank',
'payments.platform_currency', 'payments.invoice_prefix',
'payments.terms', 'payments.company_legal',
]);
$stripe = $s['payments.stripe'] ?? [];
$paypal = $s['payments.paypal'] ?? [];
$bank = $s['payments.bank'] ?? [];
$legal = $s['payments.company_legal'] ?? [];
$this->form->fill([
'platform_currency' => $s['payments.platform_currency'] ?? 'MDL',
'invoice_prefix' => $s['payments.invoice_prefix'] ?? 'INV',
'terms' => $s['payments.terms'] ?? 'Plata se efectuează în 7 zile de la emitere. Pentru întârzieri se aplică penalități 0.1%/zi.',
'legal_name' => $legal['name'] ?? null,
'legal_idno' => $legal['idno'] ?? null,
'legal_address' => $legal['address'] ?? null,
'legal_phone' => $legal['phone'] ?? null,
'legal_email' => $legal['email'] ?? null,
'stripe_enabled' => $stripe['enabled'] ?? false,
'stripe_mode' => $stripe['mode'] ?? 'test',
'stripe_publishable' => $stripe['publishable_key'] ?? null,
'stripe_secret' => $stripe['secret_key'] ?? null,
'stripe_webhook' => $stripe['webhook_secret'] ?? null,
'paypal_enabled' => $paypal['enabled'] ?? false,
'paypal_mode' => $paypal['mode'] ?? 'sandbox',
'paypal_client_id' => $paypal['client_id'] ?? null,
'paypal_secret' => $paypal['secret'] ?? null,
'bank_enabled' => $bank['enabled'] ?? true,
'bank_iban' => $bank['iban'] ?? null,
'bank_bic' => $bank['bic'] ?? null,
'bank_name' => $bank['bank_name'] ?? null,
'bank_beneficiary' => $bank['beneficiary'] ?? null,
'bank_instructions' => $bank['instructions'] ?? null,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Schemas\Components\Section::make('General')
->columns(3)
->schema([
Forms\Components\Select::make('platform_currency')
->label('Monedă platformă')
->options(['MDL' => 'MDL', 'EUR' => 'EUR', 'USD' => 'USD'])
->required(),
Forms\Components\TextInput::make('invoice_prefix')
->label('Prefix facturi')->maxLength(10)->default('INV'),
Forms\Components\Textarea::make('terms')
->label('Termeni de plată')->rows(2)->columnSpanFull(),
]),
Schemas\Components\Section::make('Date legale (apar pe facturi)')
->columns(2)
->schema([
Forms\Components\TextInput::make('legal_name')->label('Denumire companie')->maxLength(160),
Forms\Components\TextInput::make('legal_idno')->label('IDNO / CUI')->maxLength(40),
Forms\Components\TextInput::make('legal_address')->label('Adresă')->columnSpanFull()->maxLength(255),
Forms\Components\TextInput::make('legal_phone')->label('Telefon')->tel()->maxLength(40),
Forms\Components\TextInput::make('legal_email')->label('Email')->email()->maxLength(120),
]),
Schemas\Components\Section::make('💳 Stripe')
->description('Procesare carduri online. Funcționează în 30+ țări. Comision ~2.9% + 0.30€/tranzacție.')
->columns(3)
->collapsible()
->collapsed(fn (Get $get) => ! $get('stripe_enabled'))
->schema([
Forms\Components\Toggle::make('stripe_enabled')->label('Activează Stripe')->default(false)->live(),
Forms\Components\Select::make('stripe_mode')
->label('Mod')
->options(['test' => '🧪 Test', 'live' => '🟢 Live'])
->default('test')
->required(fn (Get $get) => $get('stripe_enabled'))
->visible(fn (Get $get) => $get('stripe_enabled')),
Forms\Components\TextInput::make('stripe_publishable')
->label('Publishable key')->placeholder('pk_test_...')
->visible(fn (Get $get) => $get('stripe_enabled')),
Forms\Components\TextInput::make('stripe_secret')
->label('Secret key')->password()->revealable()->placeholder('sk_test_...')
->visible(fn (Get $get) => $get('stripe_enabled')),
Forms\Components\TextInput::make('stripe_webhook')
->label('Webhook secret')->password()->revealable()->placeholder('whsec_...')
->columnSpanFull()
->helperText('Endpoint webhook: https://service.mir.md/payments/stripe/webhook')
->visible(fn (Get $get) => $get('stripe_enabled')),
]),
Schemas\Components\Section::make('🅿️ PayPal')
->description('Plăți internaționale. Mai potrivit pentru clienți din afara MD.')
->columns(3)
->collapsible()
->collapsed(fn (Get $get) => ! $get('paypal_enabled'))
->schema([
Forms\Components\Toggle::make('paypal_enabled')->label('Activează PayPal')->default(false)->live(),
Forms\Components\Select::make('paypal_mode')
->label('Mod')
->options(['sandbox' => '🧪 Sandbox', 'live' => '🟢 Live'])
->default('sandbox')
->visible(fn (Get $get) => $get('paypal_enabled')),
Forms\Components\Placeholder::make('paypal_webhook_info')
->label('Webhook')
->content('https://service.mir.md/payments/paypal/webhook')
->visible(fn (Get $get) => $get('paypal_enabled')),
Forms\Components\TextInput::make('paypal_client_id')
->label('Client ID')->placeholder('AYS...')
->visible(fn (Get $get) => $get('paypal_enabled')),
Forms\Components\TextInput::make('paypal_secret')
->label('Secret')->password()->revealable()->placeholder('EJk...')
->visible(fn (Get $get) => $get('paypal_enabled')),
]),
Schemas\Components\Section::make('🏦 Transfer bancar (manual)')
->description('Datele apar pe facturi și pe pagina de plată a tenant-ului. Confirmarea o faci manual.')
->columns(2)
->collapsible()
->schema([
Forms\Components\Toggle::make('bank_enabled')->label('Activează plata prin transfer')->default(true)->live(),
Forms\Components\TextInput::make('bank_beneficiary')
->label('Beneficiar')->placeholder('AutoCRM SRL')
->visible(fn (Get $get) => $get('bank_enabled')),
Forms\Components\TextInput::make('bank_iban')
->label('IBAN')->placeholder('MD00 AG00 0000 ...')
->visible(fn (Get $get) => $get('bank_enabled')),
Forms\Components\TextInput::make('bank_bic')
->label('BIC / SWIFT')->placeholder('AGRNMD2X')
->visible(fn (Get $get) => $get('bank_enabled')),
Forms\Components\TextInput::make('bank_name')
->label('Banca')->placeholder('Moldova-Agroindbank')
->visible(fn (Get $get) => $get('bank_enabled')),
Forms\Components\Textarea::make('bank_instructions')
->label('Instrucțiuni adiționale')->rows(2)->columnSpanFull()
->visible(fn (Get $get) => $get('bank_enabled')),
]),
])
->statePath('data');
}
public function save(): void
{
$data = $this->form->getState();
PlatformSetting::put('payments.platform_currency', $data['platform_currency']);
PlatformSetting::put('payments.invoice_prefix', $data['invoice_prefix']);
PlatformSetting::put('payments.terms', $data['terms']);
PlatformSetting::put('payments.company_legal', [
'name' => $data['legal_name'] ?? null,
'idno' => $data['legal_idno'] ?? null,
'address' => $data['legal_address'] ?? null,
'phone' => $data['legal_phone'] ?? null,
'email' => $data['legal_email'] ?? null,
]);
PlatformSetting::put('payments.stripe', [
'enabled' => (bool) ($data['stripe_enabled'] ?? false),
'mode' => $data['stripe_mode'] ?? 'test',
'publishable_key' => $data['stripe_publishable'] ?? null,
'secret_key' => $data['stripe_secret'] ?? null,
'webhook_secret' => $data['stripe_webhook'] ?? null,
]);
PlatformSetting::put('payments.paypal', [
'enabled' => (bool) ($data['paypal_enabled'] ?? false),
'mode' => $data['paypal_mode'] ?? 'sandbox',
'client_id' => $data['paypal_client_id'] ?? null,
'secret' => $data['paypal_secret'] ?? null,
]);
PlatformSetting::put('payments.bank', [
'enabled' => (bool) ($data['bank_enabled'] ?? false),
'beneficiary' => $data['bank_beneficiary'] ?? null,
'iban' => $data['bank_iban'] ?? null,
'bic' => $data['bank_bic'] ?? null,
'bank_name' => $data['bank_name'] ?? null,
'instructions' => $data['bank_instructions'] ?? null,
]);
Notification::make()->title('Setări plăți salvate')->success()->send();
}
protected function getFormActions(): array
{
return [
\Filament\Actions\Action::make('save')
->label('Salvează')
->submit('save'),
];
}
}
@@ -81,6 +81,9 @@ class CompanyResource extends Resource
->searchable(),
Forms\Components\DateTimePicker::make('trial_ends_at')->label('Trial expiră la'),
Forms\Components\DateTimePicker::make('active_until')->label('Abonament până la'),
Forms\Components\Toggle::make('is_demo')
->label('Tenant demo')
->helperText('Marchează acest tenant ca demo (datele pot fi resetate periodic).'),
]),
Schemas\Components\Section::make('Admin tenant (la creare)')
->columns(2)
@@ -112,7 +115,9 @@ class CompanyResource extends Resource
->copyable()
->url(fn (Company $record) => $record->url('/app'))
->openUrlInNewTab(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('name')
->searchable()->sortable()
->description(fn ($record) => $record->is_demo ? '🎬 demo' : null),
Tables\Columns\TextColumn::make('status')
->badge()
->colors([
@@ -53,6 +53,14 @@ class PlanResource extends Resource
Forms\Components\TextInput::make('name')->required()->maxLength(60),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Toggle::make('is_public')->label('Public (afișat la signup)')->default(true),
Forms\Components\Toggle::make('is_demo')
->label('Demo (pentru demonstrații sales)')
->helperText('Plan ascuns public, folosit pentru tenant-i demo cu features deblocate.')
->default(false),
Forms\Components\TextInput::make('trial_days')
->label('Perioadă trial (zile)')
->numeric()->placeholder('null = fără trial')
->helperText('Numărul de zile gratis după ce un tenant alege acest plan.'),
]),
Schemas\Components\Section::make('Preț')
->columns(3)
@@ -86,7 +94,10 @@ class PlanResource extends Resource
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('name')
->searchable()->sortable()
->weight('bold')
->description(fn ($record) => $record->is_demo ? '🎬 Demo' : ($record->trial_days ? "🎁 {$record->trial_days} zile trial" : null)),
Tables\Columns\TextColumn::make('price_monthly')
->money(fn ($record) => $record->currency)
->label('Lunar')
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers;
use App\Models\Central\PlatformSetting;
use App\Models\Central\Subscription;
use App\Tenancy\TenantManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* Payment flow:
* - GET /billing tenant invoices list (auth required)
* - POST /pay/{subscription} start checkout (Stripe/PayPal/Bank)
* - GET /pay/{subscription}/success marked paid via webhook in real flow
* - GET /pay/{subscription}/cancel user aborted
* - POST /payments/stripe/webhook Stripe mark paid
* - POST /payments/paypal/webhook PayPal mark paid
*/
class PaymentController
{
public function billing(Request $request)
{
$tenant = app(TenantManager::class)->current();
if (! $tenant) abort(404);
$user = $request->user();
if (! $user) {
return redirect('/app/login');
}
$invoices = Subscription::where('company_id', $tenant->id)
->latest('period_end')
->limit(20)
->get();
$methods = $this->availableMethods();
$bank = PlatformSetting::get('payments.bank', []);
$legal = PlatformSetting::get('payments.company_legal', []);
return view('site.billing', [
'tenant' => $tenant,
'invoices' => $invoices,
'methods' => $methods,
'bank' => $bank,
'legal' => $legal,
'themeColor' => $tenant->settings['theme_color'] ?? '#3B82F6',
]);
}
public function startCheckout(Request $request, int $subscription)
{
$tenant = app(TenantManager::class)->current();
$sub = Subscription::where('company_id', $tenant?->id)->findOrFail($subscription);
$method = $request->input('method');
if (! in_array($method, ['stripe', 'paypal', 'bank'], true)) {
abort(422, 'Method invalid');
}
if ($sub->status === 'paid') {
return back()->with('error', 'Factura este deja plătită');
}
if ($method === 'bank') {
// Show bank instructions page; payment is manual.
return view('site.bank-instructions', [
'tenant' => $tenant,
'sub' => $sub,
'bank' => PlatformSetting::get('payments.bank', []),
'themeColor' => $tenant->settings['theme_color'] ?? '#3B82F6',
]);
}
if ($method === 'stripe') {
return $this->startStripe($sub);
}
if ($method === 'paypal') {
return $this->startPaypal($sub);
}
}
private function startStripe(Subscription $sub)
{
$stripe = PlatformSetting::get('payments.stripe', []);
if (empty($stripe['enabled']) || empty($stripe['secret_key'])) {
return back()->with('error', 'Stripe nu este configurat. Contactează operatorul.');
}
// Real implementation requires `stripe/stripe-php` package. Here we
// produce a stub that explains the flow; in prod, replace with:
// $session = \Stripe\Checkout\Session::create([...]);
// return redirect($session->url);
return view('site.checkout-stub', [
'gateway' => 'Stripe',
'sub' => $sub,
'note' => 'Pentru a activa Stripe Checkout, instalează `composer require stripe/stripe-php` și activează în /admin/payment-settings.',
]);
}
private function startPaypal(Subscription $sub)
{
$paypal = PlatformSetting::get('payments.paypal', []);
if (empty($paypal['enabled']) || empty($paypal['client_id'])) {
return back()->with('error', 'PayPal nu este configurat.');
}
return view('site.checkout-stub', [
'gateway' => 'PayPal',
'sub' => $sub,
'note' => 'Pentru a activa PayPal, instalează `composer require srmklive/paypal` și activează în /admin/payment-settings.',
]);
}
public function success(int $subscription)
{
$sub = Subscription::findOrFail($subscription);
return view('site.payment-success', ['sub' => $sub]);
}
public function cancel(int $subscription)
{
return view('site.payment-cancel');
}
public function stripeWebhook(Request $request)
{
$stripe = PlatformSetting::get('payments.stripe', []);
$secret = $stripe['webhook_secret'] ?? null;
if (! $secret) {
Log::warning('Stripe webhook hit but no secret configured');
return response()->json(['ok' => false], 400);
}
// Real impl: \Stripe\Webhook::constructEvent($payload, $sig, $secret)
// Mock: trust the body shape for stub mode.
$payload = $request->json()->all();
$event = $payload['type'] ?? null;
if ($event === 'checkout.session.completed') {
$subId = $payload['data']['object']['metadata']['subscription_id'] ?? null;
if ($subId && $sub = Subscription::find($subId)) {
$this->markPaid($sub, 'stripe_card', $payload['data']['object']['id'] ?? null);
}
}
return response()->json(['ok' => true]);
}
public function paypalWebhook(Request $request)
{
// Real impl: verify signature with PayPal
$payload = $request->json()->all();
$event = $payload['event_type'] ?? null;
if ($event === 'CHECKOUT.ORDER.APPROVED' || $event === 'PAYMENT.CAPTURE.COMPLETED') {
$subId = $payload['resource']['custom_id'] ?? null;
if ($subId && $sub = Subscription::find($subId)) {
$this->markPaid($sub, 'paypal', $payload['resource']['id'] ?? null);
}
}
return response()->json(['ok' => true]);
}
private function markPaid(Subscription $sub, string $method, ?string $reference): void
{
if ($sub->status === 'paid') return;
$sub->update([
'status' => 'paid',
'paid_at' => now(),
'payment_method' => $method,
'reference' => $reference,
]);
// Extend company subscription
$sub->company?->update([
'status' => 'active',
'active_until' => $sub->period_end,
]);
}
private function availableMethods(): array
{
$stripe = PlatformSetting::get('payments.stripe', []);
$paypal = PlatformSetting::get('payments.paypal', []);
$bank = PlatformSetting::get('payments.bank', []);
return [
'stripe' => ! empty($stripe['enabled']) && ! empty($stripe['publishable_key']),
'paypal' => ! empty($paypal['enabled']) && ! empty($paypal['client_id']),
'bank' => ! empty($bank['enabled']) && ! empty($bank['iban']),
];
}
}
+3 -1
View File
@@ -8,7 +8,7 @@ class Plan extends Model
{
protected $fillable = [
'slug', 'name', 'price_monthly', 'price_yearly', 'currency',
'features', 'limits', 'is_active', 'is_public',
'features', 'limits', 'is_active', 'is_public', 'is_demo', 'trial_days',
];
protected $casts = [
@@ -16,6 +16,8 @@ class Plan extends Model
'limits' => 'array',
'is_active' => 'boolean',
'is_public' => 'boolean',
'is_demo' => 'boolean',
'trial_days' => 'integer',
'price_monthly' => 'decimal:2',
'price_yearly' => 'decimal:2',
];
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models\Central;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class PlatformSetting extends Model
{
public $timestamps = true;
protected $fillable = ['key', 'value'];
protected $casts = [
'value' => 'array',
];
public static function get(string $key, $default = null)
{
$cached = Cache::remember("psetting:{$key}", 300, function () use ($key) {
$row = static::where('key', $key)->first();
return $row?->value;
});
return $cached ?? $default;
}
public static function put(string $key, $value): void
{
static::updateOrCreate(['key' => $key], ['value' => $value]);
Cache::forget("psetting:{$key}");
}
public static function many(array $keys): array
{
$rows = static::whereIn('key', $keys)->get()->keyBy('key');
$out = [];
foreach ($keys as $k) {
$out[$k] = $rows[$k]?->value ?? null;
}
return $out;
}
}
@@ -124,6 +124,13 @@ class TenantPanelProvider extends PanelProvider
</style>
BLADE)
)
->userMenuItems([
'billing' => \Filament\Navigation\MenuItem::make()
->label('Facturile mele')
->icon('heroicon-o-credit-card')
->url('/billing')
->openUrlInNewTab(false),
])
->renderHook(
PanelsRenderHook::USER_MENU_BEFORE,
fn (): string => Blade::render(<<<'BLADE'
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('platform_settings', function (Blueprint $t) {
$t->id();
$t->string('key', 100)->unique();
$t->json('value')->nullable();
$t->timestamps();
});
Schema::table('plans', function (Blueprint $t) {
$t->boolean('is_demo')->default(false)->after('is_public');
$t->integer('trial_days')->nullable()->after('is_demo')
->comment('Days of free trial for tenants on this plan (null = no trial)');
});
Schema::table('companies', function (Blueprint $t) {
$t->boolean('is_demo')->default(false)->after('plan_id')
->comment('Demo tenant — features unlocked but data may be reset');
});
}
public function down(): void
{
Schema::dropIfExists('platform_settings');
Schema::table('plans', fn (Blueprint $t) => $t->dropColumn(['is_demo', 'trial_days']));
Schema::table('companies', fn (Blueprint $t) => $t->dropColumn('is_demo'));
}
};
+26
View File
@@ -76,6 +76,32 @@ class DatabaseSeeder extends Seeder
'is_active' => true, 'is_public' => true,
]);
// Demo — full features, dar marcat is_demo (nu apare public, e doar
// pentru sales/onboarding). Trial_days lipsește = sandbox permanent
// controlat de operator.
Plan::firstOrCreate(['slug' => 'demo'], [
'name' => 'Demo',
'price_monthly' => 0,
'price_yearly' => 0,
'currency' => 'MDL',
'features' => ['kanban', 'pdf', 'reports', 'ai', 'api', 'reverb', 'multi_user', 'white_label'],
'limits' => ['max_users' => 5, 'max_clients' => 50, 'max_vehicles' => 50],
'is_active' => true, 'is_public' => false, 'is_demo' => true,
'trial_days' => 14,
]);
// Trial 14 zile — plan vizibil public, devine status=trial.
Plan::firstOrCreate(['slug' => 'trial'], [
'name' => 'Trial 14 zile',
'price_monthly' => 0,
'price_yearly' => 0,
'currency' => 'MDL',
'features' => ['kanban', 'pdf', 'reports'],
'limits' => ['max_users' => 3, 'max_clients' => 100, 'max_vehicles' => 100],
'is_active' => true, 'is_public' => true, 'is_demo' => false,
'trial_days' => 14,
]);
// ─── Super-admin (operator platformă) ─────────────────────
SuperAdmin::firstOrCreate(['email' => 'vasyka.moraru@gmail.com'], [
'name' => 'Vasyka',
@@ -0,0 +1,34 @@
<x-filament-panels::page>
<form wire:submit="save">
{{ $this->form }}
<div style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
<button type="submit"
class="fi-btn fi-btn-color-primary"
style="padding: 10px 24px; background: #6366f1; color: white; border-radius: 8px; border: none; cursor: pointer; font-weight: 500;"
wire:loading.attr="disabled">
<span wire:loading.remove>💾 Salvează setările</span>
<span wire:loading>Se salvează...</span>
</button>
</div>
</form>
<div style="margin-top:24px;background:#f9fafb;border:1px dashed #e5e7eb;border-radius:8px;padding:16px;font-size:13px;line-height:1.7;">
<b style="color:#6366f1;">📚 Cum se face plata în sistem:</b>
<ol style="margin:8px 0 0 18px;padding:0;">
<li>Operatorul (tu) creezi factură: <code>/admin/companies/{id}</code> „Generează factură"</li>
<li>Tenantul vede factura în interiorul lui la <code>https://{slug}.service.mir.md/billing</code></li>
<li>Click „Plătește" → opțiuni: card (Stripe), PayPal, sau transfer manual</li>
<li>La succes: webhook automat status=paid + extends company.active_until</li>
<li>La transfer manual: tenant trimite confirmare; operator click „Marchează plătit" în /admin/subscriptions</li>
</ol>
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e5e7eb;">
<b> Pentru a primi plăți LIVE:</b>
<ul style="margin:4px 0 0 18px;padding:0;font-size:12px;">
<li>Stripe: înregistrează cont la <a href="https://dashboard.stripe.com" target="_blank" style="color:#6366f1;">dashboard.stripe.com</a> ia keys setează webhook la URL-ul de mai sus</li>
<li>PayPal: <a href="https://developer.paypal.com/dashboard/applications" target="_blank" style="color:#6366f1;">developer.paypal.com</a> creează aplicație live ia credentials</li>
<li>Transfer bancar: pune doar IBAN-ul tău, fără cont gateway</li>
</ul>
</div>
</div>
</x-filament-panels::page>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Instrucțiuni transfer bancar {{ $sub->invoice_number }}</title>
<style>
body { font-family: system-ui, sans-serif; background: #f3f4f6; padding: 24px; color: #1f2937; }
.box { max-width: 600px; margin: 0 auto; background:#fff; padding:24px; border-radius:12px; box-shadow:0 4px 12px rgba(0,0,0,.05); }
h1 { font-size: 20px; margin-bottom: 12px; }
.row { padding: 8px 0; border-bottom: 1px dashed #e5e7eb; display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.row b { color:#111827; }
.row code { background: #f9fafb; padding: 4px 8px; border-radius: 4px; font-family: ui-monospace, monospace; cursor: pointer; }
.ref-box { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 14px 16px; border-radius: 6px; margin-top: 16px; font-size: 13px; line-height: 1.6; }
.btn-back { display:inline-block; margin-top: 16px; padding: 10px 20px; background: {{ $themeColor }}; color: #fff; border-radius: 8px; text-decoration: none; font-size: 14px; }
</style>
</head>
<body>
<div class="box">
<h1>🏦 Instrucțiuni transfer bancar</h1>
<div class="row"><span>Beneficiar:</span> <b>{{ $bank['beneficiary'] ?? '—' }}</b></div>
<div class="row"><span>IBAN:</span> <code onclick="navigator.clipboard.writeText(this.textContent)">{{ $bank['iban'] ?? '—' }}</code></div>
<div class="row"><span>BIC / SWIFT:</span> <code>{{ $bank['bic'] ?? '—' }}</code></div>
<div class="row"><span>Banca:</span> {{ $bank['bank_name'] ?? '—' }}</div>
<div class="row"><span>Sumă:</span> <b>{{ number_format($sub->amount, 2) }} {{ $sub->currency }}</b></div>
<div class="ref-box">
<b> Important la „Detalii plată" / „Reference" scrie EXACT:</b><br>
<code style="font-size:14px;font-weight:700;display:block;margin:8px 0;padding:8px;background:#fff;border-radius:4px;">{{ $sub->invoice_number ?? 'INV-' . $sub->id }}</code>
Fără acest cod, plata va fi greu de identificat.
</div>
@if (! empty($bank['instructions']))
<p style="margin-top:16px;font-size:13px;line-height:1.6;color:#4b5563;">{{ $bank['instructions'] }}</p>
@endif
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb;font-size:12px;color:#6b7280;line-height:1.6;">
După efectuarea transferului:
<ul style="margin: 8px 0 0 20px;">
<li>Plata apare în 1-3 zile lucrătoare</li>
<li>Operatorul confirmă manual factura ca plătită</li>
<li>Abonamentul se extinde automat</li>
</ul>
</div>
<a href="/billing" class="btn-back"> Înapoi la facturi</a>
</div>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Facturi & abonament {{ $tenant->display_name ?? $tenant->name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; }
.wrap { max-width: 900px; margin: 0 auto; padding: 24px 16px; }
.nav-back { display: inline-block; margin-bottom: 16px; color: {{ $themeColor }}; text-decoration: none; font-size: 14px; }
h1 { font-size: 24px; margin-bottom: 4px; }
.sub { color: #6b7280; font-size: 14px; margin-bottom: 20px; }
.invoice {
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
padding: 20px; margin-bottom: 12px; display: flex; gap: 16px; align-items: center;
}
.invoice .num { font-weight: 600; font-size: 14px; flex: 0 0 120px; }
.invoice .info { flex: 1; }
.invoice .info .row { font-size: 13px; color: #6b7280; }
.invoice .info .row b { color: #1f2937; }
.invoice .amount { font-weight: 700; font-size: 20px; min-width: 100px; text-align: right; }
.invoice .actions { flex: 0 0 auto; }
.badge { display:inline-block; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.badge-paid { background:#dcfce7; color:#166534; }
.badge-pending { background:#fef3c7; color:#92400e; }
.badge-overdue { background:#fee2e2; color:#991b1b; }
.btn-pay {
background: {{ $themeColor }}; color: #fff; padding: 10px 20px; border-radius: 8px;
border: none; cursor: pointer; font-size: 14px; font-weight: 600; text-decoration: none;
display: inline-block;
}
.btn-pay:hover { opacity: .9; }
.empty { text-align: center; padding: 60px 20px; color: #6b7280; }
.modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index: 100; align-items: center; justify-content: center; }
.modal.open { display: flex; }
.modal-box { background:#fff; border-radius:12px; padding:24px; max-width:480px; width: 90%; }
.modal-box h3 { margin-bottom: 8px; font-size: 18px; }
.modal-box p { color:#6b7280; font-size: 13px; margin-bottom: 16px; }
.method {
display: flex; gap: 12px; align-items: center; padding: 12px;
border: 2px solid #e5e7eb; border-radius: 8px; cursor: pointer; margin-bottom: 8px;
transition: border-color .15s;
}
.method:hover { border-color: {{ $themeColor }}; }
.method.disabled { opacity: .4; cursor: not-allowed; }
.method .icon { font-size: 24px; }
.method .label { font-weight: 600; flex: 1; font-size: 14px; }
.method .desc { font-size: 11px; color: #6b7280; }
.method-form { display: inline; }
.method-btn { all: unset; cursor: pointer; width: 100%; }
.modal-close { float:right; cursor:pointer; color:#9ca3af; font-size: 22px; }
</style>
</head>
<body>
<div class="wrap">
<a href="/app" class="nav-back"> Înapoi la AutoCRM</a>
<h1>Facturi & abonament</h1>
<p class="sub">{{ $tenant->display_name ?? $tenant->name }} · {{ $tenant->slug }}.service.mir.md</p>
@if ($invoices->isEmpty())
<div class="empty">
<div style="font-size:48px;margin-bottom:8px;">📄</div>
<div style="font-size:16px;font-weight:600;margin-bottom:4px;">Nicio factură emisă încă</div>
<div style="font-size:13px;">Operatorul îți va emite factură când e timpul abonamentului.</div>
</div>
@else
@foreach ($invoices as $inv)
<div class="invoice">
<div class="num">
{{ $inv->invoice_number ?? '#' . $inv->id }}
</div>
<div class="info">
<div class="row"><b>{{ $inv->plan?->name ?? 'Abonament' }}</b> · {{ $inv->period === 'yearly' ? 'Anual' : 'Lunar' }}</div>
<div class="row">{{ $inv->period_start?->format('d.m.Y') }} {{ $inv->period_end?->format('d.m.Y') }}</div>
<div class="row" style="margin-top:6px;">
<span class="badge badge-{{ $inv->status === 'paid' ? 'paid' : ($inv->status === 'overdue' ? 'overdue' : 'pending') }}">
@switch($inv->status)
@case('paid') Plătit @if($inv->paid_at) la {{ $inv->paid_at->format('d.m.Y') }} @endif @break
@case('overdue') Întârziat @break
@case('pending') În așteptare @break
@default {{ $inv->status }}
@endswitch
</span>
@if ($inv->due_at)
<span style="margin-left:8px;color:#6b7280;font-size:11px;">scadent {{ $inv->due_at->format('d.m.Y') }}</span>
@endif
</div>
</div>
<div class="amount">{{ number_format($inv->amount, 2) }} {{ $inv->currency }}</div>
<div class="actions">
@if ($inv->status === 'paid')
<span style="color:#10b981;font-weight:600;"></span>
@else
<button class="btn-pay" onclick="document.getElementById('m-{{ $inv->id }}').classList.add('open')">
💳 Plătește
</button>
@endif
</div>
</div>
{{-- Modal de plată --}}
<div id="m-{{ $inv->id }}" class="modal" onclick="if(event.target===this)this.classList.remove('open')">
<div class="modal-box">
<span class="modal-close" onclick="document.getElementById('m-{{ $inv->id }}').classList.remove('open')">×</span>
<h3>Plătește {{ $inv->invoice_number ?? '#' . $inv->id }}</h3>
<p>Sumă: <b>{{ number_format($inv->amount, 2) }} {{ $inv->currency }}</b></p>
@if ($methods['stripe'])
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
@csrf
<input type="hidden" name="method" value="stripe">
<button class="method-btn">
<div class="method">
<div class="icon">💳</div>
<div>
<div class="label">Card bancar</div>
<div class="desc">Visa, Mastercard prin Stripe</div>
</div>
</div>
</button>
</form>
@endif
@if ($methods['paypal'])
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
@csrf
<input type="hidden" name="method" value="paypal">
<button class="method-btn">
<div class="method">
<div class="icon">🅿️</div>
<div>
<div class="label">PayPal</div>
<div class="desc">Cont PayPal sau card prin PayPal</div>
</div>
</div>
</button>
</form>
@endif
@if ($methods['bank'])
<form method="POST" action="{{ route('pay.start', ['subscription' => $inv->id]) }}" class="method-form">
@csrf
<input type="hidden" name="method" value="bank">
<button class="method-btn">
<div class="method">
<div class="icon">🏦</div>
<div>
<div class="label">Transfer bancar</div>
<div class="desc">Plătește direct din bancă (1-3 zile)</div>
</div>
</div>
</button>
</form>
@endif
@if (! $methods['stripe'] && ! $methods['paypal'] && ! $methods['bank'])
<div style="text-align:center;color:#9ca3af;padding:32px 16px;font-size:13px;">
Nicio metodă de plată configurată. Contactează operatorul.
</div>
@endif
</div>
</div>
@endforeach
@endif
@if (! empty($legal['name']))
<div style="margin-top:32px;padding:16px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;font-size:12px;color:#6b7280;">
<b>Operator platformă:</b> {{ $legal['name'] }}
@if (! empty($legal['idno'])) · IDNO {{ $legal['idno'] }} @endif
@if (! empty($legal['address'])) · {{ $legal['address'] }} @endif
@if (! empty($legal['email'])) · {{ $legal['email'] }} @endif
</div>
@endif
</div>
</body>
</html>
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ $gateway }} Checkout</title>
<style>
body { font-family: system-ui, sans-serif; background: #f3f4f6; padding: 60px 20px; color: #1f2937; text-align: center; }
.box { max-width: 540px; margin: 0 auto; background:#fff; padding:40px 24px; border-radius:12px; box-shadow:0 4px 12px rgba(0,0,0,.05); }
h1 { font-size: 22px; margin-bottom: 12px; }
.note { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 12px 16px; border-radius: 6px; margin: 16px 0; text-align: left; font-size: 13px; line-height: 1.6; }
.btn { display:inline-block; padding: 10px 20px; background: #6366f1; color: #fff; border-radius: 8px; text-decoration: none; margin-top: 12px; font-size: 14px; }
</style>
</head>
<body>
<div class="box">
<div style="font-size:48px;margin-bottom:12px;">🚧</div>
<h1>{{ $gateway }} Checkout</h1>
<p style="color:#6b7280;font-size:14px;">Factură: <b>{{ $sub->invoice_number ?? '#' . $sub->id }}</b> · {{ number_format($sub->amount, 2) }} {{ $sub->currency }}</p>
<div class="note">
<b>Mod stub:</b><br>
{{ $note }}<br><br>
În prod, aici redirectează la pagina {{ $gateway }} Checkout cu sumele și metadatele facturii. La success, webhook-ul marchează automat factura ca plătită.
</div>
<a href="/billing" class="btn"> Înapoi</a>
</div>
</body>
</html>
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ro">
<head><meta charset="UTF-8"><title>Plată anulată</title>
<style>
body { font-family: system-ui, sans-serif; background: #fef2f2; padding: 60px 20px; text-align: center; color: #1f2937; }
.box { max-width: 480px; margin: 0 auto; background:#fff; padding:40px 24px; border-radius:12px; }
.btn { display:inline-block; margin-top: 16px; padding: 10px 24px; background: #6b7280; color: #fff; border-radius: 8px; text-decoration: none; }
</style>
</head>
<body>
<div class="box">
<div style="font-size:48px;"></div>
<h1 style="color:#ef4444;">Plată anulată</h1>
<p style="color:#6b7280;">Nu a fost prelevată nicio sumă. Poți încerca din nou oricând.</p>
<a href="/billing" class="btn"> Înapoi la facturi</a>
</div>
</body>
</html>
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Plată reușită</title>
<style>
body { font-family: system-ui, sans-serif; background: #f0fdf4; padding: 60px 20px; text-align: center; color: #1f2937; }
.box { max-width: 480px; margin: 0 auto; background:#fff; padding:40px 24px; border-radius:12px; box-shadow:0 4px 12px rgba(0,0,0,.05); }
h1 { color: #10b981; font-size: 24px; margin: 12px 0; }
.btn { display:inline-block; margin-top: 16px; padding: 10px 24px; background: #10b981; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 500; }
</style>
</head>
<body>
<div class="box">
<div style="font-size:64px;"></div>
<h1>Plată reușită!</h1>
<p style="color:#6b7280;">Factura {{ $sub->invoice_number ?? '#' . $sub->id }} a fost achitată.</p>
<p style="color:#6b7280;font-size:13px;margin-top:8px;">Abonamentul tău a fost extins.</p>
<a href="/app" class="btn"> Mergi la AutoCRM</a>
</div>
</body>
</html>
+20
View File
@@ -24,6 +24,26 @@ Route::get('/', function () {
return redirect('/admin');
});
// ─── Plăți / Billing (tenant-side) ──────────────────────────────────
// /billing — listă facturi tenant + buton plată
// /pay/{id} — start checkout (Stripe / PayPal / bank)
Route::get('/billing', [\App\Http\Controllers\PaymentController::class, 'billing'])->name('billing');
Route::post('/pay/{subscription}', [\App\Http\Controllers\PaymentController::class, 'startCheckout'])
->where('subscription', '\d+')
->name('pay.start');
Route::get('/pay/{subscription}/success', [\App\Http\Controllers\PaymentController::class, 'success'])
->where('subscription', '\d+')
->name('pay.success');
Route::get('/pay/{subscription}/cancel', [\App\Http\Controllers\PaymentController::class, 'cancel'])
->where('subscription', '\d+')
->name('pay.cancel');
// ─── Webhooks (central, no auth) ────────────────────────────────────
Route::post('/payments/stripe/webhook', [\App\Http\Controllers\PaymentController::class, 'stripeWebhook'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
Route::post('/payments/paypal/webhook', [\App\Http\Controllers\PaymentController::class, 'paypalWebhook'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
// Stub `login` route — needed because Laravel's auth middleware tries to
// route('login') when redirecting unauthenticated requests. We don't have a
// global /login (panels use /admin/login and /app/login), so stub it.