Zezo Ai php Saas

📊 2 مشاهدة

شرح PHP-SaaS

تعمّق أعمق: بناء أنظمة إنتاجية كاملة لمشروع SaaS


الجزء الأول: بناء Service للفوترة (Billing Service)

لماذا Service منفصل؟

  • فصل المسؤوليات: Controllers تتعامل مع HTTP فقط، منطق الفوترة معزول.
  • قابلية الاختبار: يمكن اختبار الخدمة دون HTTP.
  • إعادة الاستخدام: نفس الخدمة تُستخدم في الويب، API، وConsole.
  • التبديل السهل: تغيير Stripe إلى Paddle دون لمس Controllers.

البنية

text

app/Services/Billing/
├── BillingService.php          # الواجهة الرئيسية
├── Contracts/
│   └── PaymentGateway.php      # عقد موحد لأي بوابة دفع
├── Gateways/
│   ├── StripeGateway.php
│   └── PaddleGateway.php
├── DTOs/
│   ├── SubscriptionData.php
│   └── InvoiceData.php
└── Exceptions/
    ├── PaymentFailedException.php
    └── PlanLimitExceededException.php

عقد بوابة الدفع

php

namespace App\Services\Billing\Contracts;

use App\Models\User;
use App\Services\Billing\DTOs\SubscriptionData;

interface PaymentGateway
{
    public function createSubscription(User $user, string $planId, string $paymentMethod): SubscriptionData;
    public function cancelSubscription(User $user): void;
    public function resumeSubscription(User $user): void;
    public function swapPlan(User $user, string $newPlanId): SubscriptionData;
    public function getInvoices(User $user): array;
    public function getPaymentMethod(User $user): ?array;
}

DTO للاشتراك

php

namespace App\Services\Billing\DTOs;

use Carbon\Carbon;

readonly class SubscriptionData
{
    public function __construct(
        public string $id,
        public string $planId,
        public string $status,
        public Carbon $currentPeriodEnd,
        public ?Carbon $trialEnd = null,
        public bool $cancelAtPeriodEnd = false,
    ) {}

    public static function fromStripe(\Laravel\Cashier\Subscription $sub): self
    {
        return new self(
            id: $sub->stripe_id,
            planId: $sub->stripe_price,
            status: $sub->stripe_status,
            currentPeriodEnd: Carbon::createFromTimestamp($sub->current_period_end),
            trialEnd: $sub->trial_ends_at,
            cancelAtPeriodEnd: $sub->ends_at !== null,
        );
    }
}

Stripe Gateway

php

namespace App\Services\Billing\Gateways;

use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;
use App\Services\Billing\Exceptions\PaymentFailedException;
use Stripe\Exception\CardException;

class StripeGateway implements PaymentGateway
{
    public function createSubscription(User $user, string $planId, string $paymentMethod): SubscriptionData
    {
        try {
            $subscription = $user->newSubscription('default', $planId)
                ->trialDays(config('saas.trial_days', 14))
                ->create($paymentMethod);

            return SubscriptionData::fromStripe($subscription);
        } catch (CardException $e) {
            throw new PaymentFailedException($e->getMessage(), previous: $e);
        }
    }

    public function cancelSubscription(User $user): void
    {
        $user->subscription('default')?->cancel();
    }

    public function resumeSubscription(User $user): void
    {
        $user->subscription('default')?->resume();
    }

    public function swapPlan(User $user, string $newPlanId): SubscriptionData
    {
        $sub = $user->subscription('default');

        if (! $sub) {
            throw new \RuntimeException('No active subscription');
        }

        // إذا كانت الترقية، استخدم proration
        $sub->swap($newPlanId);

        return SubscriptionData::fromStripe($sub->fresh());
    }

    public function getInvoices(User $user): array
    {
        return $user->invoices()->map(fn ($inv) => [
            'id' => $inv->id,
            'date' => $inv->date(),
            'total' => $inv->total(),
            'url' => $inv->invoicePdf(),
        ])->toArray();
    }

    public function getPaymentMethod(User $user): ?array
    {
        $pm = $user->defaultPaymentMethod();

        if (! $pm) {
            return null;
        }

        return [
            'brand' => $pm->card->brand,
            'last4' => $pm->card->last4,
            'exp_month' => $pm->card->exp_month,
            'exp_year' => $pm->card->exp_year,
        ];
    }
}

Billing Service (الواجهة)

php

namespace App\Services\Billing;

use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;

class BillingService
{
    public function __construct(
        private readonly PaymentGateway $gateway
    ) {}

    public function subscribe(User $user, string $planId, string $paymentMethod): SubscriptionData
    {
        // تحقق من وجود اشتراك مسبق
        if ($user->subscribed('default')) {
            return $this->gateway->swapPlan($user, $planId);
        }

        return $this->gateway->createSubscription($user, $planId, $paymentMethod);
    }

    public function cancel(User $user): void
    {
        $this->gateway->cancelSubscription($user);
    }

    public function resume(User $user): void
    {
        $this->gateway->resumeSubscription($user);
    }

    public function upgrade(User $user, string $planId): SubscriptionData
    {
        return $this->gateway->swapPlan($user, $planId);
    }
}

ربط الخدمة بالحاوية

php

// app/Providers/AppServiceProvider.php
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\Gateways\StripeGateway;

public function register(): void
{
    $this->app->bind(PaymentGateway::class, function () {
        return match (config('saas.gateway')) {
            'stripe' => new StripeGateway(),
            'paddle' => new \App\Services\Billing\Gateways\PaddleGateway(),
        };
    });

    $this->app->singleton(BillingService::class, function ($app) {
        return new BillingService($app->make(PaymentGateway::class));
    });
}

Controller يستخدم الخدمة

php

namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function __construct(
        private readonly BillingService $billing
    ) {}

    public function store(Request $request)
    {
        $data = $request->validate([
            'plan' => 'required|string|exists:plans,id',
            'payment_method' => 'required|string',
        ]);

        $subscription = $this->billing->subscribe(
            $request->user(),
            $data['plan'],
            $data['payment_method']
        );

        return response()->json([
            'message' => 'تم الاشتراك بنجاح',
            'subscription' => $subscription,
        ]);
    }

    public function cancel(Request $request)
    {
        $this->billing->cancel($request->user());

        return back()->with('status', 'سيتم إلغاء الاشتراك في نهاية الدورة');
    }
}

اختبار الخدمة

php

namespace Tests\Unit\Services;

use App\Models\User;
use App\Services\Billing\BillingService;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\SubscriptionData;
use Carbon\Carbon;
use Mockery;
use Tests\TestCase;

class BillingServiceTest extends TestCase
{
    public function test_subscribe_creates_new_when_no_active_subscription(): void
    {
        $user = User::factory()->create();

        $gateway = Mockery::mock(PaymentGateway::class);
        $gateway->shouldReceive('createSubscription')
            ->once()
            ->andReturn(new SubscriptionData(
                id: 'sub_123',
                planId: 'price_pro',
                status: 'active',
                currentPeriodEnd: Carbon::now()->addMonth(),
            ));

        $service = new BillingService($gateway);
        $result = $service->subscribe($user, 'price_pro', 'pm_card');

        $this->assertEquals('sub_123', $result->id);
    }
}

الجزء الثاني: اختبار Webhooks باستخدام Stripe CLI

تثبيت Stripe CLI

bash

# macOS
brew install stripe/stripe-cli/stripe

# Linux
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_x86_64.tar.gz
tar -xvf stripe_linux_x86_64.tar.gz

# تسجيل الدخول
stripe login

تشغيل Webhook محلياً

bash

# يوجّه أحداث Stripe إلى تطبيقك المحلي
stripe listen --forward-to localhost:8000/stripe/webhook

ستحصل على:

text

> Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx

انسخه إلى .env:

env

STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx

إرسال أحداث تجريبية

bash

# محاكاة اشتراك ناجح
stripe trigger customer.subscription.created

# محاكاة فشل دفع
stripe trigger invoice.payment_failed

# محاكاة إلغاء اشتراك
stripe trigger customer.subscription.deleted

اختبار Webhook برمجياً (Pest)

php

use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;

it('handles subscription created webhook', function () {
    Event::fake([WebhookReceived::class]);

    $payload = [
        'id' => 'evt_test',
        'type' => 'customer.subscription.created',
        'data' => [
            'object' => [
                'id' => 'sub_test',
                'customer' => 'cus_test',
                'status' => 'active',
            ],
        ],
    ];

    $this->postJson('/stripe/webhook', $payload, [
        'Stripe-Signature' => $this->generateStripeSignature($payload),
    ])->assertOk();

    Event::assertDispatched(WebhookReceived::class);
});

function generateStripeSignature(array $payload): string
{
    $secret = config('cashier.webhook.secret');
    $timestamp = time();
    $signedPayload = $timestamp.'.'.json_encode($payload);
    $signature = hash_hmac('sha256', $signedPayload, $secret);

    return "t={$timestamp},v1={$signature}";
}

اختبارات التكامل مع Stripe Test Mode

php

use Laravel\Cashier\Testing\TestCase;

class StripeIntegrationTest extends TestCase
{
    public function test_full_subscription_flow()
    {
        $user = User::factory()->create();
        $user->createAsStripeCustomer();

        // إنشاء طريقة دفع تجريبية
        $paymentMethod = $user->createSetupIntent()->payment_method;

        // اشتراك
        $subscription = $user->newSubscription('default', 'price_test_pro')
            ->create($paymentMethod);

        $this->assertTrue($user->subscribed('default'));

        // إلغاء
        $subscription->cancel();
        $this->assertTrue($user->subscription('default')->onGracePeriod());
    }
}

الجزء الثالث: Middleware لحدود الخطط

تعريف الخطط في الإعدادات

php

// config/saas.php
return [
    'plans' => [
        'free' => [
            'name' => 'مجاني',
            'price' => 0,
            'limits' => [
                'projects' => 3,
                'team_members' => 2,
                'api_calls' => 1000,
                'storage_mb' => 100,
            ],
            'features' => ['basic_support'],
        ],
        'pro' => [
            'name' => 'احترافي',
            'price' => 29,
            'limits' => [
                'projects' => 50,
                'team_members' => 10,
                'api_calls' => 100000,
                'storage_mb' => 10000,
            ],
            'features' => ['basic_support', 'api_access', 'custom_domain'],
        ],
        'enterprise' => [
            'name' => 'مؤسسي',
            'price' => 299,
            'limits' => [
                'projects' => PHP_INT_MAX,
                'team_members' => PHP_INT_MAX,
                'api_calls' => PHP_INT_MAX,
                'storage_mb' => PHP_INT_MAX,
            ],
            'features' => ['*'],
        ],
    ],
];

خدمة الخطط

php

namespace App\Services;

use App\Models\User;

class PlanService
{
    public function planFor(User $user): array
    {
        $planId = $user->plan ?? 'free';

        return config("saas.plans.{$planId}");
    }

    public function limit(User $user, string $key): int
    {
        return $this->planFor($user)['limits'][$key] ?? 0;
    }

    public function hasFeature(User $user, string $feature): bool
    {
        $features = $this->planFor($user)['features'];

        return in_array('*', $features) || in_array($feature, $features);
    }

    public function usage(User $user, string $key): int
    {
        return match ($key) {
            'projects' => $user->projects()->count(),
            'team_members' => $user->team->members()->count(),
            'api_calls' => cache()->get("api_calls:{$user->id}", 0),
            'storage_mb' => $user->files()->sum('size_mb'),
            default => 0,
        };
    }

    public function can(User $user, string $key): bool
    {
        return $this->usage($user, $key) < $this->limit($user, $key);
    }
}

Middleware للحدود

php

namespace App\Http\Middleware;

use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;

class EnforcePlanLimit
{
    public function __construct(
        private readonly PlanService $plans
    ) {}

    public function handle(Request $request, Closure $next, string $limitKey)
    {
        $user = $request->user();

        if (! $this->plans->can($user, $limitKey)) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => "وصلت للحد الأقصى من {$limitKey}",
                    'limit' => $this->plans->limit($user, $limitKey),
                    'usage' => $this->plans->usage($user, $limitKey),
                    'upgrade_url' => route('billing.plans'),
                ], 429);
            }

            return redirect()->route('billing.plans')
                ->with('error', "وصلت للحد الأقصى من {$limitKey}");
        }

        return $next($request);
    }
}

Middleware للميزات

php

namespace App\Http\Middleware;

use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;

class RequireFeature
{
    public function __construct(
        private readonly PlanService $plans
    ) {}

    public function handle(Request $request, Closure $next, string $feature)
    {
        if (! $this->plans->hasFeature($request->user(), $feature)) {
            abort(403, 'هذه الميزة غير متاحة في خطتك الحالية');
        }

        return $next($request);
    }
}

تسجيل Middleware

php

// app/Http/Kernel.php
protected $middlewareAliases = [
    'plan.limit' => \App\Http\Middleware\EnforcePlanLimit::class,
    'plan.feature' => \App\Http\Middleware\RequireFeature::class,
];

الاستخدام في Routes

php

// routes/web.php
Route::middleware(['auth'])->group(function () {
    Route::post('/projects', [ProjectController::class, 'store'])
        ->middleware('plan.limit:projects');

    Route::get('/api-keys', [ApiKeyController::class, 'index'])
        ->middleware('plan.feature:api_access');
});

عدّاد API Calls

php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class CountApiCalls
{
    public function handle(Request $request, Closure $next)
    {
        $key = "api_calls:{$request->user()->id}";

        $count = cache()->increment($key);

        // إعادة تعيين العداد كل شهر
        if ($count === 1) {
            cache()->put($key, 1, now()->addMonth());
        }

        return $next($request);
    }
}

الجزء الرابع: إعداد CI/CD لمشروع Laravel SaaS

GitHub Actions — الاختبارات

yaml

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s

      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: --health-cmd="redis-cli ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, redis, bcmath, gd
          coverage: xdebug

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-progress

      - name: Copy .env
        run: cp .env.testing .env

      - name: Generate key
        run: php artisan key:generate

      - name: Run Pint
        run: vendor/bin/pint --test

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --memory-limit=2G

      - name: Run Tests
        run: vendor/bin/pest --coverage --min=80

      - name: Upload coverage
        uses: codecov/codecov-action@v4

GitHub Actions — النشر

yaml

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: [test]

    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy via SSH
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
            cd /var/www/saas
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart
            php artisan horizon:terminate
          EOF

سكربت النشر (Deployer)

php

// deploy.php
namespace Deployer;

require 'recipe/laravel.php';

host('production')
    ->set('hostname', 'saas.example.com')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/saas');

host('staging')
    ->set('hostname', 'staging.saas.example.com')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/saas-staging');

// مهام مخصصة
task('tenants:migrate', function () {
    run('{{release_path}}/artisan tenants:migrate --force');
});

after('artisan:migrate', 'tenants:migrate');

// إعادة تشغيل Horizon
task('horizon:restart', function () {
    run('{{release_path}}/artisan horizon:terminate');
});

after('deploy:symlink', 'horizon:restart');

bash

# النشر
dep deploy production

# التراجع
dep rollback production

الجزء الخامس: المراقبة والتنبيهات

Sentry — تتبع الأخطاء

php

// config/sentry.php
return [
    'dsn' => env('SENTRY_LARAVEL_DSN'),
    'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.2),
    'profiles_sample_rate' => 0.2,
    'environment' => env('APP_ENV'),
];

php

// app/Exceptions/Handler.php
use Sentry\Laravel\Integration;

public function register(): void
{
    $this->reportable(function (\Throwable $e) {
        Integration::captureUnhandledException($e);
    });
}

Horizon — مراقبة Queues

php

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default', 'billing', 'notifications'],
            'balance' => 'auto',
            'minProcesses' => 1,
            'maxProcesses' => 10,
            'tries' => 3,
            'timeout' => 60,
        ],
    ],
],

bash

# تشغيل Horizon
php artisan horizon

# في Supervisor
[program:horizon]
command=php /var/www/saas/artisan horizon
autostart=true
autorestart=true
user=deploy

Health Checks

php

// routes/web.php
Route::get('/health', function () {
    return response()->json([
        'status' => 'ok',
        'database' => DB::connection()->getPdo() ? 'connected' : 'failed',
        'redis' => Redis::ping() ? 'connected' : 'failed',
        'queue' => Queue::size() < 1000 ? 'ok' : 'backlog',
        'timestamp' => now()->toIso8601String(),
    ]);
});

تنبيهات Slack

php

// app/Notifications/SystemAlert.php
namespace App\Notifications;

use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\SlackMessage;

class SystemAlert extends Notification
{
    public function __construct(
        public string $title,
        public string $message,
        public string $level = 'warning'
    ) {}

    public function via($notifiable): array
    {
        return ['slack'];
    }

    public function toSlack($notifiable): SlackMessage
    {
        return (new SlackMessage)
            ->{$this->level}()
            ->title($this->title)
            ->content($this->message)
            ->timestamp(now());
    }
}

php

// إرسال تنبيه عند فشل Webhook
Notification::route('slack', config('services.slack.webhook'))
    ->notify(new SystemAlert(
        'فشل Webhook',
        "فشل معالجة حدث Stripe: {$eventId}",
        'error'
    ));

الجزء السادس: الأمان المتقدم

قائمة تحقق أمنية

البندالأداة/الإجراء
حماية CSRFمدمج في Laravel
XSSBlade escaping تلقائي
SQL InjectionEloquent/Query Builder
Rate LimitingRateLimiter::for('api', ...)
2FApragmarx/google2fa
تشفير البيانات الحساسةCrypt::encryptString()
تدقيق التبعياتcomposer audit
تحليل ثابتnunomaduro/larastan
اختبار اختراقأدوات خارجية (Burp, OWASP ZAP)
سجلات التدقيقspatie/laravel-activitylog

Rate Limiting متقدم

php

// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

protected function configureRateLimiting(): void
{
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();

        return Limit::perMinute(match ($user?->plan) {
            'enterprise' => 1000,
            'pro' => 100,
            default => 20,
        })->by($user?->id ?: $request->ip());
    });

    RateLimiter::for('login', function (Request $request) {
        return [
            Limit::perMinute(5)->by($request->ip()),
            Limit::perMinute(10)->by($request->input('email')),
        ];
    });
}

تشفير بيانات حساسة

php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;

class Tenant extends Model
{
    protected function stripeSecret(): Attribute
    {
        return Attribute::make(
            get: fn ($value) => $value ? Crypt::decryptString($value) : null,
            set: fn ($value) => $value ? Crypt::encryptString($value) : null,
        );
    }
}

الجزء السابع: خارطة طريق 90 يوماً

الشهر الأول: الأساس

  • □ تثبيت Laravel + Breeze
  • □ إعداد PostgreSQL + Redis
  • □ مصادقة + أدوار (Spatie Permission)
  • □ تصميم قاعدة البيانات
  • □ اختبارات أساسية

الشهر الثاني: الميزات الأساسية

  • □ تعدد المستأجرين (stancl/tenancy)
  • □ الفوترة (Cashier + Stripe)
  • □ لوحة الإدارة (Filament)
  • □ حدود الخطط (Pennant + Middleware)
  • □ إشعارات بريدية

الشهر الثالث: الجاهزية للإنتاج

  • □ اختبارات شاملة (>80%)
  • □ CI/CD
  • □ مراقبة (Sentry + Horizon)
  • □ نسخ احتياطي (spatie/laravel-backup)
  • □ اختبار حمل
  • □ توثيق API (Scramble أو Scribe)
  • □ إطلاق تجريبي (Beta)

الجزء الثامن: أدوات إضافية تستحق الاستكشاف

الأداةالوظيفة
laravel/scoutبحث متقدم (Meilisearch, Algolia, TNTSearch)
laravel/reverbWebSockets (بديل Pusher)
laravel/folioصفحات Vue/React داخل Laravel
laravel/voltمكونات Blade تفاعلية
spatie/laravel-typescript-transformerتوليد TypeScript من PHP
dedoc/scrambleتوثيق API تلقائي
knuckleswtf/scribeتوثيق API شامل
laravel/pulseمراقبة أداء (رسمي)
spatie/laravel-onboardتتبع خطوات الإعداد الأولي (Onboarding)
spatie/laravel-cspContent Security Policy

الجزء التاسع: أخطاء شائعة يجب تجنبها

  1. تجاهل Multi-tenancy مبكراً: إضافته لاحقاً مؤلمة.
  2. تخزين منطق الفوترة في Controllers: يصبح غير قابل للاختبار.
  3. عدم اختبار Webhooks: أكثر مصدر للأخطاء في الإنتاج.
  4. استخدام env() خارج config: يفشل بعد config:cache.
  5. عدم تتبع استخدام API: يؤدي لتجاوز الحدود.
  6. نسخ .env من التطوير للإنتاج: خطر أمني.
  7. عدم إعداد Supervisor للـ Queues: الـ Jobs لا تعمل.
  8. تجاهل النسخ الاحتياطي: كارثة عند فشل DB.
  9. عدم مراقبة الأداء: المشاكل تُكتشف متأخراً.
  10. التوسع مبكراً: لا تبنِ لـ 100,000 مستخدم قبل أن تصل لـ 100.

هل تريد أن أتعمق في:

  • laravel/scout + Meilisearch لبناء بحث SaaS؟
  • laravel/reverb للإشعارات الفورية؟
  • dedoc/scramble لتوثيق API تلقائياً؟
  • اختبار الحمل (Load Testing) باستخدام k6؟
  • إعداد Kubernetes لنشر SaaS؟

This response is AI-generated, for reference only.

الكود + العملاء قم بالشحن باستخدام الذكاء الاصطناعي الذي يقوم بما تتوقعه – بغض النظر عن طريقة البناء.cashier إعداد CI/CD لمشروع Laravel SaaS أمثلة كود لـ Middleware حدود الخطط؟ بناء Service للفوترة مع مثال كامل؟

الحزمة الكاملة: الكود + العملاء (Billing + Clients) + CI/CD + Middleware + AI


الجزء الأول: بناء Service الفوترة الكامل مع دعم العملاء

هيكل المشروع الكامل

text

app/
├── Services/Billing/
│   ├── BillingService.php
│   ├── Contracts/
│   │   └── PaymentGateway.php
│   ├── Gateways/
│   │   ├── StripeGateway.php
│   │   └── PaddleGateway.php
│   ├── DTOs/
│   │   ├── CustomerData.php
│   │   ├── SubscriptionData.php
│   │   └── InvoiceData.php
│   └── Exceptions/
│       ├── PaymentFailedException.php
│       └── CustomerNotFoundException.php
├── Models/
│   ├── Customer.php
│   ├── Subscription.php
│   └── Invoice.php
└── Http/Controllers/Billing/
    ├── CustomerController.php
    ├── SubscriptionController.php
    └── InvoiceController.php

1.1 نموذج العميل (Customer Model)

php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Customer extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'tenant_id',
        'user_id',
        'email',
        'name',
        'company',
        'vat_number',
        'country',
        'currency',
        'stripe_id',
        'paddle_id',
        'metadata',
    ];

    protected $casts = [
        'metadata' => 'array',
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function subscriptions(): HasMany
    {
        return $this->hasMany(Subscription::class);
    }

    public function invoices(): HasMany
    {
        return $this->hasMany(Invoice::class);
    }

    public function activeSubscription(): ?Subscription
    {
        return $this->subscriptions()
            ->whereIn('status', ['active', 'trialing'])
            ->latest()
            ->first();
    }

    public function isSubscribed(): bool
    {
        return $this->activeSubscription() !== null;
    }
}

1.2 نموذج الاشتراك (Subscription Model)

php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
{
    protected $fillable = [
        'customer_id',
        'plan_id',
        'gateway',
        'gateway_id',
        'status',
        'trial_ends_at',
        'current_period_start',
        'current_period_end',
        'canceled_at',
        'ends_at',
        'quantity',
        'metadata',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'current_period_start' => 'datetime',
        'current_period_end' => 'datetime',
        'canceled_at' => 'datetime',
        'ends_at' => 'datetime',
        'metadata' => 'array',
    ];

    public function customer()
    {
        return $this->belongsTo(Customer::class);
    }

    public function isActive(): bool
    {
        return in_array($this->status, ['active', 'trialing'])
            && ($this->ends_at === null || $this->ends_at->isFuture());
    }

    public function onGracePeriod(): bool
    {
        return $this->ends_at !== null && $this->ends_at->isFuture();
    }

    public function isCanceled(): bool
    {
        return $this->canceled_at !== null;
    }
}

1.3 DTOs

php

// app/Services/Billing/DTOs/CustomerData.php
namespace App\Services\Billing\DTOs;

readonly class CustomerData
{
    public function __construct(
        public string $id,
        public string $email,
        public string $name,
        public ?string $company = null,
        public ?string $vatNumber = null,
        public string $currency = 'usd',
        public array $metadata = [],
    ) {}

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'email' => $this->email,
            'name' => $this->name,
            'company' => $this->company,
            'vat_number' => $this->vatNumber,
            'currency' => $this->currency,
            'metadata' => $this->metadata,
        ];
    }
}

php

// app/Services/Billing/DTOs/SubscriptionData.php
namespace App\Services\Billing\DTOs;

use Carbon\Carbon;

readonly class SubscriptionData
{
    public function __construct(
        public string $id,
        public string $planId,
        public string $status,
        public Carbon $currentPeriodStart,
        public Carbon $currentPeriodEnd,
        public ?Carbon $trialEnd = null,
        public ?Carbon $endsAt = null,
        public bool $cancelAtPeriodEnd = false,
        public int $quantity = 1,
    ) {}

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'plan_id' => $this->planId,
            'status' => $this->status,
            'current_period_start' => $this->currentPeriodStart->toIso8601String(),
            'current_period_end' => $this->currentPeriodEnd->toIso8601String(),
            'trial_end' => $this->trialEnd?->toIso8601String(),
            'ends_at' => $this->endsAt?->toIso8601String(),
            'cancel_at_period_end' => $this->cancelAtPeriodEnd,
            'quantity' => $this->quantity,
        ];
    }
}

php

// app/Services/Billing/DTOs/InvoiceData.php
namespace App\Services\Billing\DTOs;

use Carbon\Carbon;

readonly class InvoiceData
{
    public function __construct(
        public string $id,
        public string $number,
        public Carbon $date,
        public int $amount,
        public string $currency,
        public string $status,
        public ?string $pdfUrl = null,
        public ?string $hostedUrl = null,
    ) {}

    public function formattedAmount(): string
    {
        return number_format($this->amount / 100, 2).' '.strtoupper($this->currency);
    }
}

1.4 عقد بوابة الدفع الموسع

php

namespace App\Services\Billing\Contracts;

use App\Models\Customer;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;

interface PaymentGateway
{
    // العملاء
    public function createCustomer(CustomerData $data): CustomerData;
    public function updateCustomer(Customer $customer, array $data): CustomerData;
    public function deleteCustomer(Customer $customer): void;

    // الاشتراكات
    public function createSubscription(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): SubscriptionData;
    public function cancelSubscription(Customer $customer): void;
    public function resumeSubscription(Customer $customer): void;
    public function swapPlan(Customer $customer, string $newPlanId): SubscriptionData;

    // الفواتير
    public function getInvoices(Customer $customer): array;
    public function getInvoice(Customer $customer, string $invoiceId): InvoiceData;
    public function downloadInvoice(Customer $customer, string $invoiceId): string;

    // طرق الدفع
    public function getPaymentMethod(Customer $customer): ?array;
    public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void;
}

1.5 Stripe Gateway الكامل

php

namespace App\Services\Billing\Gateways;

use App\Models\Customer;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;
use App\Services\Billing\Exceptions\PaymentFailedException;
use Carbon\Carbon;
use Stripe\Exception\CardException;
use Stripe\StripeClient;

class StripeGateway implements PaymentGateway
{
    private StripeClient $stripe;

    public function __construct()
    {
        $this->stripe = new StripeClient(config('cashier.secret'));
    }

    public function createCustomer(CustomerData $data): CustomerData
    {
        $customer = $this->stripe->customers->create([
            'email' => $data->email,
            'name' => $data->name,
            'metadata' => array_merge($data->metadata, [
                'company' => $data->company,
                'vat_number' => $data->vatNumber,
            ]),
        ]);

        return new CustomerData(
            id: $customer->id,
            email: $customer->email,
            name: $customer->name,
            company: $data->company,
            vatNumber: $data->vatNumber,
            currency: $data->currency,
            metadata: $customer->metadata->toArray(),
        );
    }

    public function updateCustomer(Customer $customer, array $data): CustomerData
    {
        $updated = $this->stripe->customers->update($customer->stripe_id, $data);

        return new CustomerData(
            id: $updated->id,
            email: $updated->email,
            name: $updated->name,
            company: $updated->metadata['company'] ?? null,
            vatNumber: $updated->metadata['vat_number'] ?? null,
            currency: $customer->currency,
            metadata: $updated->metadata->toArray(),
        );
    }

    public function deleteCustomer(Customer $customer): void
    {
        $this->stripe->customers->delete($customer->stripe_id);
    }

    public function createSubscription(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): SubscriptionData
    {
        try {
            // إرفاق طريقة الدفع
            $this->stripe->paymentMethods->attach($paymentMethod, [
                'customer' => $customer->stripe_id,
            ]);

            $this->stripe->customers->update($customer->stripe_id, [
                'invoice_settings' => ['default_payment_method' => $paymentMethod],
            ]);

            $subscription = $this->stripe->subscriptions->create([
                'customer' => $customer->stripe_id,
                'items' => [['price' => $planId, 'quantity' => $quantity]],
                'trial_period_days' => config('saas.trial_days', 14),
                'expand' => ['latest_invoice.payment_intent'],
            ]);

            return $this->mapSubscription($subscription);
        } catch (CardException $e) {
            throw new PaymentFailedException($e->getMessage(), previous: $e);
        }
    }

    public function cancelSubscription(Customer $customer): void
    {
        $sub = $customer->activeSubscription();

        if (! $sub) {
            return;
        }

        $this->stripe->subscriptions->update($sub->gateway_id, [
            'cancel_at_period_end' => true,
        ]);
    }

    public function resumeSubscription(Customer $customer): void
    {
        $sub = $customer->activeSubscription();

        if (! $sub) {
            return;
        }

        $this->stripe->subscriptions->update($sub->gateway_id, [
            'cancel_at_period_end' => false,
        ]);
    }

    public function swapPlan(Customer $customer, string $newPlanId): SubscriptionData
    {
        $sub = $customer->activeSubscription();

        if (! $sub) {
            throw new \RuntimeException('No active subscription');
        }

        $stripeSub = $this->stripe->subscriptions->retrieve($sub->gateway_id);

        $updated = $this->stripe->subscriptions->update($sub->gateway_id, [
            'items' => [[
                'id' => $stripeSub->items->data[0]->id,
                'price' => $newPlanId,
            ]],
            'proration_behavior' => 'create_prorations',
        ]);

        return $this->mapSubscription($updated);
    }

    public function getInvoices(Customer $customer): array
    {
        $invoices = $this->stripe->invoices->all([
            'customer' => $customer->stripe_id,
            'limit' => 100,
        ]);

        return array_map(fn ($inv) => $this->mapInvoice($inv), $invoices->data);
    }

    public function getInvoice(Customer $customer, string $invoiceId): InvoiceData
    {
        $invoice = $this->stripe->invoices->retrieve($invoiceId);

        return $this->mapInvoice($invoice);
    }

    public function downloadInvoice(Customer $customer, string $invoiceId): string
    {
        $invoice = $this->stripe->invoices->retrieve($invoiceId);

        return $invoice->invoice_pdf;
    }

    public function getPaymentMethod(Customer $customer): ?array
    {
        $methods = $this->stripe->paymentMethods->all([
            'customer' => $customer->stripe_id,
            'type' => 'card',
        ]);

        if (empty($methods->data)) {
            return null;
        }

        $pm = $methods->data[0];

        return [
            'id' => $pm->id,
            'brand' => $pm->card->brand,
            'last4' => $pm->card->last4,
            'exp_month' => $pm->card->exp_month,
            'exp_year' => $pm->card->exp_year,
        ];
    }

    public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void
    {
        $this->stripe->customers->update($customer->stripe_id, [
            'invoice_settings' => ['default_payment_method' => $paymentMethodId],
        ]);
    }

    private function mapSubscription($subscription): SubscriptionData
    {
        return new SubscriptionData(
            id: $subscription->id,
            planId: $subscription->items->data[0]->price->id,
            status: $subscription->status,
            currentPeriodStart: Carbon::createFromTimestamp($subscription->current_period_start),
            currentPeriodEnd: Carbon::createFromTimestamp($subscription->current_period_end),
            trialEnd: $subscription->trial_end ? Carbon::createFromTimestamp($subscription->trial_end) : null,
            endsAt: $subscription->cancel_at ? Carbon::createFromTimestamp($subscription->cancel_at) : null,
            cancelAtPeriodEnd: $subscription->cancel_at_period_end,
            quantity: $subscription->items->data[0]->quantity,
        );
    }

    private function mapInvoice($invoice): InvoiceData
    {
        return new InvoiceData(
            id: $invoice->id,
            number: $invoice->number ?? '',
            date: Carbon::createFromTimestamp($invoice->created),
            amount: $invoice->total,
            currency: $invoice->currency,
            status: $invoice->status,
            pdfUrl: $invoice->invoice_pdf,
            hostedUrl: $invoice->hosted_invoice_url,
        );
    }
}

1.6 Billing Service الكامل

php

namespace App\Services\Billing;

use App\Models\Customer;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\Contracts\PaymentGateway;
use App\Services\Billing\DTOs\CustomerData;
use App\Services\Billing\DTOs\InvoiceData;
use App\Services\Billing\DTOs\SubscriptionData;
use Illuminate\Support\Facades\DB;

class BillingService
{
    public function __construct(
        private readonly PaymentGateway $gateway
    ) {}

    /**
     * إنشاء عميل جديد (محلياً + في بوابة الدفع)
     */
    public function createCustomer(User $user, array $data): Customer
    {
        return DB::transaction(function () use ($user, $data) {
            $customerData = new CustomerData(
                id: '',
                email: $data['email'] ?? $user->email,
                name: $data['name'] ?? $user->name,
                company: $data['company'] ?? null,
                vatNumber: $data['vat_number'] ?? null,
                currency: $data['currency'] ?? config('saas.currency', 'usd'),
                metadata: ['user_id' => $user->id, 'tenant_id' => $user->tenant_id],
            );

            $gatewayCustomer = $this->gateway->createCustomer($customerData);

            return Customer::create([
                'user_id' => $user->id,
                'tenant_id' => $user->tenant_id,
                'email' => $gatewayCustomer->email,
                'name' => $gatewayCustomer->name,
                'company' => $gatewayCustomer->company,
                'vat_number' => $gatewayCustomer->vatNumber,
                'currency' => $gatewayCustomer->currency,
                'stripe_id' => $gatewayCustomer->id,
                'metadata' => $gatewayCustomer->metadata,
            ]);
        });
    }

    /**
     * اشتراك جديد
     */
    public function subscribe(Customer $customer, string $planId, string $paymentMethod, int $quantity = 1): Subscription
    {
        return DB::transaction(function () use ($customer, $planId, $paymentMethod, $quantity) {
            // إذا كان لديه اشتراك نشط، قم بالترقية
            if ($active = $customer->activeSubscription()) {
                return $this->swapPlan($customer, $planId);
            }

            $data = $this->gateway->createSubscription($customer, $planId, $paymentMethod, $quantity);

            return Subscription::create([
                'customer_id' => $customer->id,
                'plan_id' => $data->planId,
                'gateway' => config('saas.gateway', 'stripe'),
                'gateway_id' => $data->id,
                'status' => $data->status,
                'trial_ends_at' => $data->trialEnd,
                'current_period_start' => $data->currentPeriodStart,
                'current_period_end' => $data->currentPeriodEnd,
                'ends_at' => $data->endsAt,
                'quantity' => $data->quantity,
            ]);
        });
    }

    /**
     * إلغاء (في نهاية الدورة)
     */
    public function cancel(Customer $customer): void
    {
        $sub = $customer->activeSubscription();

        if (! $sub) {
            return;
        }

        $this->gateway->cancelSubscription($customer);

        $sub->update([
            'canceled_at' => now(),
            'ends_at' => $sub->current_period_end,
        ]);
    }

    /**
     * استئناف بعد الإلغاء
     */
    public function resume(Customer $customer): void
    {
        $sub = $customer->subscriptions()
            ->whereNotNull('ends_at')
            ->where('ends_at', '>', now())
            ->latest()
            ->first();

        if (! $sub) {
            return;
        }

        $this->gateway->resumeSubscription($customer);

        $sub->update([
            'canceled_at' => null,
            'ends_at' => null,
        ]);
    }

    /**
     * ترقية/تغيير خطة
     */
    public function swapPlan(Customer $customer, string $newPlanId): Subscription
    {
        $sub = $customer->activeSubscription();

        if (! $sub) {
            throw new \RuntimeException('No active subscription to swap');
        }

        $data = $this->gateway->swapPlan($customer, $newPlanId);

        $sub->update([
            'plan_id' => $data->planId,
            'status' => $data->status,
            'current_period_start' => $data->currentPeriodStart,
            'current_period_end' => $data->currentPeriodEnd,
            'quantity' => $data->quantity,
        ]);

        return $sub->fresh();
    }

    /**
     * جلب الفواتير
     */
    public function invoices(Customer $customer): array
    {
        return $this->gateway->getInvoices($customer);
    }

    /**
     * تحميل فاتورة PDF
     */
    public function downloadInvoice(Customer $customer, string $invoiceId): string
    {
        return $this->gateway->downloadInvoice($customer, $invoiceId);
    }

    /**
     * طريقة الدفع الحالية
     */
    public function paymentMethod(Customer $customer): ?array
    {
        return $this->gateway->getPaymentMethod($customer);
    }

    /**
     * تحديث طريقة الدفع
     */
    public function updatePaymentMethod(Customer $customer, string $paymentMethodId): void
    {
        $this->gateway->updatePaymentMethod($customer, $paymentMethodId);
    }
}

1.7 Controllers

php

// app/Http/Controllers/Billing/CustomerController.php
namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function __construct(
        private readonly BillingService $billing
    ) {}

    public function store(Request $request)
    {
        $data = $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:customers,email',
            'company' => 'nullable|string|max:255',
            'vat_number' => 'nullable|string|max:50',
            'currency' => 'nullable|string|size:3',
        ]);

        $customer = $this->billing->createCustomer($request->user(), $data);

        return response()->json($customer, 201);
    }
}

php

// app/Http/Controllers/Billing/SubscriptionController.php
namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function __construct(
        private readonly BillingService $billing
    ) {}

    public function store(Request $request)
    {
        $data = $request->validate([
            'plan_id' => 'required|string',
            'payment_method' => 'required|string',
            'quantity' => 'nullable|integer|min:1',
        ]);

        $customer = $request->user()->customer;

        if (! $customer) {
            return response()->json(['message' => 'Customer not found'], 404);
        }

        $subscription = $this->billing->subscribe(
            $customer,
            $data['plan_id'],
            $data['payment_method'],
            $data['quantity'] ?? 1
        );

        return response()->json($subscription, 201);
    }

    public function cancel(Request $request)
    {
        $this->billing->cancel($request->user()->customer);

        return response()->json(['message' => 'Subscription will cancel at period end']);
    }

    public function resume(Request $request)
    {
        $this->billing->resume($request->user()->customer);

        return response()->json(['message' => 'Subscription resumed']);
    }

    public function swap(Request $request)
    {
        $data = $request->validate(['plan_id' => 'required|string']);

        $subscription = $this->billing->swapPlan(
            $request->user()->customer,
            $data['plan_id']
        );

        return response()->json($subscription);
    }
}

php

// app/Http/Controllers/Billing/InvoiceController.php
namespace App\Http\Controllers\Billing;

use App\Http\Controllers\Controller;
use App\Services\Billing\BillingService;
use Illuminate\Http\Request;

class InvoiceController extends Controller
{
    public function __construct(
        private readonly BillingService $billing
    ) {}

    public function index(Request $request)
    {
        $invoices = $this->billing->invoices($request->user()->customer);

        return response()->json($invoices);
    }

    public function download(Request $request, string $invoiceId)
    {
        $url = $this->billing->downloadInvoice(
            $request->user()->customer,
            $invoiceId
        );

        return redirect($url);
    }
}

الجزء الثاني: دمج الذكاء الاصطناعي (AI) في الفوترة والعملاء

2.1 خدمة AI للتنبؤ بالسلوك

php

// app/Services/AI/CustomerInsightsService.php
namespace App\Services\AI;

use App\Models\Customer;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

class CustomerInsightsService
{
    /**
     * التنبؤ بخطر إلغاء الاشتراك (Churn Prediction)
     */
    public function churnRisk(Customer $customer): array
    {
        return Cache::remember("churn:{$customer->id}", now()->addHours(6), function () use ($customer) {
            $features = $this->extractFeatures($customer);

            $response = Http::withHeaders([
                'Authorization' => 'Bearer '.config('services.openai.key'),
            ])->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o-mini',
                'messages' => [
                    [
                        'role' => 'system',
                        'content' => 'You are a SaaS churn prediction expert. Return JSON: {"risk_score": 0-1, "reasons": [], "recommendations": []}',
                    ],
                    [
                        'role' => 'user',
                        'content' => json_encode($features),
                    ],
                ],
                'response_format' => ['type' => 'json_object'],
            ]);

            return json_decode($response->json('choices.0.message.content'), true);
        });
    }

    /**
     * استخراج الميزات لتحليل AI
     */
    private function extractFeatures(Customer $customer): array
    {
        $sub = $customer->activeSubscription();

        return [
            'plan' => $sub?->plan_id,
            'days_since_signup' => $customer->created_at->diffInDays(now()),
            'days_since_last_login' => optional($customer->user->last_login_at)->diffInDays(now()) ?? 999,
            'failed_payments' => $customer->invoices()->where('status', 'failed')->count(),
            'total_invoices' => $customer->invoices()->count(),
            'has_trial' => $sub?->trial_ends_at !== null,
            'subscription_age_days' => $sub?->created_at->diffInDays(now()) ?? 0,
            'usage_trend' => $this->usageTrend($customer),
        ];
    }

    private function usageTrend(Customer $customer): string
    {
        // مقارنة استخدام آخر 30 يوم بالسابق
        $recent = cache()->get("usage:{$customer->id}:recent", 0);
        $previous = cache()->get("usage:{$customer->id}:previous", 0);

        if ($previous === 0) {
            return 'unknown';
        }

        $ratio = $recent / $previous;

        return match (true) {
            $ratio > 1.2 => 'growing',
            $ratio < 0.8 => 'declining',
            default => 'stable',
        };
    }

    /**
     * توصية بخطة مناسبة
     */
    public function recommendPlan(Customer $customer): array
    {
        $usage = [
            'projects' => $customer->user->projects()->count(),
            'team_members' => $customer->user->team?->members()->count() ?? 1,
            'api_calls' => cache()->get("api_calls:{$customer->user_id}", 0),
        ];

        $plans = config('saas.plans');
        $bestPlan = null;
        $bestScore = PHP_INT_MAX;

        foreach ($plans as $planId => $plan) {
            $score = 0;
            foreach ($usage as $key => $value) {
                $limit = $plan['limits'][$key] ?? 0;
                if ($value > $limit) {
                    $score += ($value - $limit) * 100; // عقوبة تجاوز
                } else {
                    $score += ($limit - $value) / 10; // كلما زاد الفراغ قلّت الجدارة
                }
            }
            if ($score < $bestScore) {
                $bestScore = $score;
                $bestPlan = $planId;
            }
        }

        return [
            'recommended_plan' => $bestPlan,
            'current_usage' => $usage,
            'reason' => 'Based on current usage patterns',
        ];
    }
}

2.2 AI Agent لخدمة العملاء (Billing Assistant)

php

// app/Services/AI/BillingAssistant.php
namespace App\Services\AI;

use App\Models\Customer;
use App\Services\Billing\BillingService;
use Illuminate\Support\Facades\Http;

class BillingAssistant
{
    public function __construct(
        private readonly BillingService $billing,
        private readonly CustomerInsightsService $insights
    ) {}

    /**
     * مساعد ذكي يجيب على أسئلة الفوترة ويقترح إجراءات
     */
    public function ask(Customer $customer, string $question): array
    {
        $context = $this->buildContext($customer);

        $response = Http::withHeaders([
            'Authorization' => 'Bearer '.config('services.openai.key'),
        ])->post('https://api.openai.com/v1/chat/completions', [
            'model' => 'gpt-4o-mini',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'أنت مساعد فوترة ذكي. أجب بالعربية. أرجع JSON: {"answer": "...", "suggested_action": "...", "action_payload": {}}',
                ],
                [
                    'role' => 'user',
                    'content' => "سياق العميل: ".json_encode($context)."\n\nسؤال العميل: {$question}",
                ],
            ],
            'response_format' => ['type' => 'json_object'],
        ]);

        return json_decode($response->json('choices.0.message.content'), true);
    }

    private function buildContext(Customer $customer): array
    {
        $sub = $customer->activeSubscription();

        return [
            'name' => $customer->name,
            'plan' => $sub?->plan_id ?? 'free',
            'status' => $sub?->status ?? 'none',
            'current_period_end' => $sub?->current_period_end?->toDateString(),
            'invoices_count' => $customer->invoices()->count(),
            'churn_risk' => $this->insights->churnRisk($customer)['risk_score'] ?? null,
            'recommended_plan' => $this->insights->recommendPlan($customer)['recommended_plan'] ?? null,
        ];
    }

    /**
     * تنفيذ إجراء مقترح من AI (بعد موافقة المستخدم)
     */
    public function executeAction(Customer $customer, string $action, array $payload): mixed
    {
        return match ($action) {
            'cancel_subscription' => $this->billing->cancel($customer),
            'resume_subscription' => $this->billing->resume($customer),
            'upgrade_plan' => $this->billing->swapPlan($customer, $payload['plan_id']),
            'download_invoice' => $this->billing->downloadInvoice($customer, $payload['invoice_id']),
            default => throw new \InvalidArgumentException("Unknown action: {$action}"),
        };
    }
}

2.3 استخراج بيانات الفواتير بالـ AI (OCR + LLM)

php

// app/Services/AI/InvoiceParserService.php
namespace App\Services\AI;

use Illuminate\Support\Facades\Http;
use Illuminate\Http\UploadedFile;

class InvoiceParserService
{
    /**
     * استخراج بيانات من فاتورة PDF/صورة
     */
    public function parse(UploadedFile $file): array
    {
        // 1. رفع الملف إلى OpenAI Vision
        $base64 = base64_encode(file_get_contents($file->getRealPath()));

        $response = Http::withHeaders([
            'Authorization' => 'Bearer '.config('services.openai.key'),
        ])->post('https://api.openai.com/v1/chat/completions', [
            'model' => 'gpt-4o',
            'messages' => [
                [
                    'role' => 'user',
                    'content' => [
                        [
                            'type' => 'text',
                            'text' => 'استخرج بيانات الفاتورة كـ JSON: {"vendor": "", "invoice_number": "", "date": "", "amount": 0, "currency": "", "tax": 0, "items": []}',
                        ],
                        [
                            'type' => 'image_url',
                            'image_url' => ['url' => "data:{$file->getMimeType()};base64,{$base64}"],
                        ],
                    ],
                ],
            ],
            'response_format' => ['type' => 'json_object'],
        ]);

        return json_decode($response->json('choices.0.message.content'), true);
    }
}

2.4 AI Chatbot مدمج مع Filament

php

// app/Filament/Pages/BillingAssistant.php
namespace App\Filament\Pages;

use App\Services\AI\BillingAssistant;
use Filament\Pages\Page;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;

class BillingAssistantPage extends Page implements HasForms
{
    use InteractsWithForms;

    protected static ?string $navigationIcon = 'heroicon-o-sparkles';
    protected static ?string $navigationLabel = 'مساعد الفوترة AI';
    protected static string $view = 'filament.pages.billing-assistant';

    public ?array $data = [];
    public array $messages = [];

    public function mount(): void
    {
        $this->form->fill();
    }

    public function form(Form $form): Form
    {
        return $form->schema([
            Textarea::make('question')
                ->label('اسأل عن أي شيء يخص الفوترة')
                ->required()
                ->rows(3),
        ])->statePath('data');
    }

    public function ask(BillingAssistant $assistant): void
    {
        $customer = auth()->user()->customer;
        $result = $assistant->ask($customer, $this->data['question']);

        $this->messages[] = [
            'role' => 'user',
            'content' => $this->data['question'],
        ];
        $this->messages[] = [
            'role' => 'assistant',
            'content' => $result['answer'],
            'action' => $result['suggested_action'] ?? null,
            'payload' => $result['action_payload'] ?? null,
        ];

        $this->data['question'] = '';
    }

    public function executeSuggested(string $action, array $payload, BillingAssistant $assistant): void
    {
        $assistant->executeAction(auth()->user()->customer, $action, $payload);
        $this->messages[] = ['role' => 'system', 'content' => 'تم تنفيذ الإجراء بنجاح'];
    }
}

الجزء الثالث: Middleware حدود الخطط (أمثلة كود كاملة)

3.1 Middleware للحدود العامة

php

// app/Http/Middleware/EnforcePlanLimit.php
namespace App\Http\Middleware;

use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;

class EnforcePlanLimit
{
    public function __construct(
        private readonly PlanService $plans
    ) {}

    public function handle(Request $request, Closure $next, string $limitKey)
    {
        $user = $request->user();

        if (! $user) {
            return $next($request);
        }

        if (! $this->plans->can($user, $limitKey)) {
            $limit = $this->plans->limit($user, $limitKey);
            $usage = $this->plans->usage($user, $limitKey);

            if ($request->expectsJson()) {
                return response()->json([
                    'error' => 'plan_limit_exceeded',
                    'message' => "وصلت للحد الأقصى من {$limitKey}",
                    'limit' => $limit,
                    'usage' => $usage,
                    'upgrade_url' => route('billing.plans'),
                ], 429);
            }

            return redirect()->route('billing.plans')
                ->with('error', "وصلت للحد الأقصى من {$limitKey} ({$usage}/{$limit})");
        }

        return $next($request);
    }
}

3.2 Middleware للميزات

php

// app/Http/Middleware/RequireFeature.php
namespace App\Http\Middleware;

use App\Services\PlanService;
use Closure;
use Illuminate\Http\Request;

class RequireFeature
{
    public function __construct(
        private readonly PlanService $plans
    ) {}

    public function handle(Request $request, Closure $next, string $feature)
    {
        if (! $this->plans->hasFeature($request->user(), $feature)) {
            abort(403, 'هذه الميزة غير متاحة في خطتك الحالية');
        }

        return $next($request);
    }
}

3.3 Middleware لعدّاد API

php

// app/Http/Middleware/CountApiCalls.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

class CountApiCalls
{
    public function handle(Request $request, Closure $next)
    {
        $user = $request->user();

        if (! $user) {
            return $next($request);
        }

        $key = "api_calls:{$user->id}:".now()->format('Y-m');

        Redis::incr($key);
        Redis::expire($key, 60 * 60 * 24 * 40); // 40 يوم

        return $next($request);
    }
}

3.4 Middleware للتحقق من الاشتراك

php

// app/Http/Middleware/RequireActiveSubscription.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class RequireActiveSubscription
{
    public function handle(Request $request, Closure $next, ?string $plan = null)
    {
        $customer = $request->user()?->customer;

        if (! $customer || ! $customer->isSubscribed()) {
            if ($request->expectsJson()) {
                return response()->json(['error' => 'subscription_required'], 403);
            }

            return redirect()->route('billing.plans')
                ->with('error', 'يجب الاشتراك للوصول لهذه الصفحة');
        }

        if ($plan && $customer->activeSubscription()->plan_id !== $plan) {
            abort(403, "هذه الصفحة تتطلب خطة {$plan}");
        }

        return $next($request);
    }
}

3.5 تسجيل كل Middleware

php

// app/Http/Kernel.php
protected $middlewareAliases = [
    'plan.limit' => \App\Http\Middleware\EnforcePlanLimit::class,
    'plan.feature' => \App\Http\Middleware\RequireFeature::class,
    'plan.subscription' => \App\Http\Middleware\RequireActiveSubscription::class,
    'api.counter' => \App\Http\Middleware\CountApiCalls::class,
];

protected $middlewareGroups = [
    'api' => [
        'throttle:api',
        \App\Http\Middleware\CountApiCalls::class,
    ],
];

3.6 الاستخدام في Routes

php

// routes/web.php
Route::middleware(['auth', 'plan.subscription'])->group(function () {
    Route::get('/dashboard', DashboardController::class);

    Route::post('/projects', [ProjectController::class, 'store'])
        ->middleware('plan.limit:projects');

    Route::get('/api-keys', [ApiKeyController::class, 'index'])
        ->middleware('plan.feature:api_access');

    Route::get('/team/invite', [TeamController::class, 'invite'])
        ->middleware('plan.limit:team_members');
});

// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::get('/user', fn (Request $r) => $r->user());
});

3.7 اختبار Middleware (Pest)

php

use App\Models\User;
use App\Services\PlanService;

it('blocks project creation when limit reached', function () {
    $user = User::factory()->create(['plan' => 'free']);
    $user->projects()->createMany(array_fill(0, 3, ['name' => 'Project']));

    $this->actingAs($user)
        ->postJson('/projects', ['name' => 'New Project'])
        ->assertStatus(429)
        ->assertJsonPath('error', 'plan_limit_exceeded');
});

it('allows project creation under limit', function () {
    $user = User::factory()->create(['plan' => 'pro']);

    $this->actingAs($user)
        ->postJson('/projects', ['name' => 'New Project'])
        ->assertStatus(201);
});

it('blocks API access on free plan', function () {
    $user = User::factory()->create(['plan' => 'free']);

    $this->actingAs($user)
        ->getJson('/api-keys')
        ->assertStatus(403);
});

الجزء الرابع: إعداد CI/CD كامل

4.1 .github/workflows/ci.yml

yaml

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    name: Code Style (Pint)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/pint --test

  static-analysis:
    name: Static Analysis (PHPStan)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/phpstan analyse --memory-limit=2G

  test:
    name: Tests (PHP ${{ matrix.php }})
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3']

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testing
        ports: [3306:3306]
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3

      redis:
        image: redis:7
        ports: [6379:6379]
        options: --health-cmd="redis-cli ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, pdo, pdo_mysql, bcmath, gd, redis, xdebug
          coverage: xdebug

      - run: composer install --prefer-dist --no-progress

      - name: Prepare environment
        run: |
          cp .env.testing .env
          php artisan key:generate

      - name: Run migrations
        run: php artisan migrate --force
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: root

      - name: Run tests
        run: vendor/bin/pest --parallel --coverage --min=80
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: root
          REDIS_HOST: 127.0.0.1

      - name: Upload coverage
        if: matrix.php == '8.3'
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage.xml

  security:
    name: Security Audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: composer install --prefer-dist --no-progress
      - run: composer audit --format=json || true

4.2 .github/workflows/deploy.yml

yaml

name: Deploy

on:
  push:
    branches: [main]
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    if: github.event.workflow_run.conclusion == 'success' || github.event_name == 'push'
    environment: staging

    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy
        run: |
          ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
            set -e
            cd /var/www/saas-staging
            git fetch origin main
            git reset --hard origin/main
            composer install --no-dev --optimize-autoloader --no-interaction
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan event:cache
            php artisan queue:restart
            php artisan horizon:terminate
          EOF

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.PROD_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H ${{ secrets.PROD_SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy
        run: |
          ssh ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} << 'EOF'
            set -e
            cd /var/www/saas
            # نسخ احتياطي قبل النشر
            php artisan backup:run --only-db
            git fetch origin main
            git reset --hard origin/main
            composer install --no-dev --optimize-autoloader --no-interaction
            php artisan down --retry=60 || true
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan event:cache
            php artisan up
            php artisan queue:restart
            php artisan horizon:terminate
          EOF

4.3 deploy.php (Deployer)

php

<?php
namespace Deployer;

require 'recipe/laravel.php';

// إعداد المضيفين
host('staging')
    ->setHostname('staging.saas.example.com')
    ->setRemoteUser('deploy')
    ->setDeployPath('/var/www/saas-staging')
    ->set('branch', 'develop');

host('production')
    ->setHostname('saas.example.com')
    ->setRemoteUser('deploy')
    ->setDeployPath('/var/www/saas')
    ->set('branch', 'main');

// مهام مخصصة
task('tenants:migrate', function () {
    run('{{release_path}}/artisan tenants:migrate --force');
});

task('horizon:restart', function () {
    run('{{release_path}}/artisan horizon:terminate');
});

task('backup:database', function () {
    run('{{release_path}}/artisan backup:run --only-db');
});

// خطوات النشر
after('deploy:update_code', 'backup:database');
after('artisan:migrate', 'tenants:migrate');
after('deploy:symlink', 'horizon:restart');

// إعدادات
set('keep_releases', 5);
set('application', 'saas');
set('repository', 'git@github.com:your-org/saas.git');

4.4 Dockerfile للإنتاج

dockerfile

# Dockerfile
FROM php:8.3-fpm-alpine AS base

RUN apk add --no-cache \
    git curl libpng-dev libzip-dev oniguruma-dev \
    mysql-client supervisor nginx \
    && docker-php-ext-install pdo pdo_mysql bcmath gd zip opcache

# Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps

# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

# نسخ الكود
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

COPY . .
RUN composer dump-autoload --optimize \
    && php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache

RUN chown -R www-data:www-data storage bootstrap/cache

USER www-data
EXPOSE 9000
CMD ["php-fpm"]

4.5 docker-compose.yml للتطوير

yaml

version: '3.8'

services:
  app:
    build:
      context: .
      target: base
    volumes:
      - .:/var/www/html
    depends_on:
      - mysql
      - redis
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis

  nginx:
    image: nginx:alpine
    ports:
      - "8000:80"
    volumes:
      - .:/var/www/html
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: saas
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  horizon:
    build:
      context: .
      target: base
    command: php artisan horizon
    volumes:
      - .:/var/www/html
    depends_on:
      - redis
      - mysql

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

volumes:
  mysql_data:

4.6 اختبارات الحمل (k6)

javascript

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 50 },
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'https://staging.saas.example.com';
const TOKEN = __ENV.API_TOKEN;

export default function () {
  const res = http.get(`${BASE_URL}/api/projects`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(1);
}

bash

k6 run load-test.js

الجزء الخامس: ملخص الحزمة الكاملة

المكوّنالملفالوظيفة
Customer Modelapp/Models/Customer.phpإدارة العملاء محلياً
Subscription Modelapp/Models/Subscription.phpتتبع الاشتراكات
DTOsapp/Services/Billing/DTOs/*نقل بيانات نظيف
PaymentGatewayapp/Services/Billing/Contracts/PaymentGateway.phpعقد موحد
StripeGatewayapp/Services/Billing/Gateways/StripeGateway.phpتنفيذ Stripe
BillingServiceapp/Services/Billing/BillingService.phpمنطق الأعمال
Controllersapp/Http/Controllers/Billing/*واجهات HTTP
AI Insightsapp/Services/AI/CustomerInsightsService.phpتنبؤ Churn + توصيات
AI Assistantapp/Services/AI/BillingAssistant.phpمساعد ذكي
AI Parserapp/Services/AI/InvoiceParserService.phpاستخراج فواتير
Middlewareapp/Http/Middleware/*حدود + ميزات + عدّاد
CI.github/workflows/ci.ymlفحص + اختبار
CD.github/workflows/deploy.yml + deploy.phpنشر تلقائي
DockerDockerfile + docker-compose.ymlبيئة موحدة
Load Testload-test.jsاختبار حمل

الجزء السادس: خطوات التشغيل السريع

bash

# 1. استنساخ المشروع
git clone git@github.com:your-org/saas.git && cd saas

# 2. تثبيت الحزم
composer install

# 3. إعداد البيئة
cp .env.example .env
php artisan key:generate

# 4. قاعدة البيانات
docker-compose up -d mysql redis
php artisan migrate

# 5. تشغيل التطوير
docker-compose up -d
php artisan serve
php artisan horizon

# 6. اختبار الـ Webhooks
stripe listen --forward-to localhost:8000/stripe/webhook

# 7. الاختبارات
vendor/bin/pest
vendor/bin/pint
vendor/bin/phpstan analyse

# 8. النشر
dep deploy production

هل تريد أن أتعمق في:

  • laravel/scout + Meilisearch للبحث في العملاء والفواتير؟
  • laravel/reverb للإشعارات الفورية عند نجاح/فشل الدفع؟
  • إعداد Kubernetes (Deployment + HPA + Ingress) لـ SaaS؟
  • اختبارات E2E باستخدام Playwright لتدفق الاشتراك الكامل؟
Scroll to Top

🤖 موصى به لك بناءً على اهتماماتك