Zezo Ai Saas 📚 الملحق الهندسي العميق — SallaBridge v1.2

📊 2 مشاهدة

 — SallaBridge v1.2

الطبقة الثالثة: التنفيذ الحرفي، الكود الكامل، السيناريوهات الحقيقية، التكاملات المتقدمة، والتفاصيل التي تفصل بين “فكرة” و”منتج يُباع”.


🏛️ القسم 31: المعمارية العامة (System Architecture)

31.1 المخطط الطبوغرافي

text

                    ┌──────────────────────────────────────────────┐
                    │           CLIENT LAYER                       │
                    │  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
                    │  │ React    │  │ Mobile   │  │ Public   │   │
                    │  │ Admin    │  │ (v3)     │  │ API      │   │
                    │  └────┬─────┘  └────┬─────┘  └────┬─────┘   │
                    └───────┼─────────────┼─────────────┼─────────┘
                            │             │             │
                    ┌───────▼─────────────▼─────────────▼─────────┐
                    │         CDN / WAF (Cloudflare)               │
                    └───────────────────┬──────────────────────────┘
                                        │ HTTPS
                    ┌───────────────────▼──────────────────────────┐
                    │      LOAD BALANCER (Nginx / ALB)             │
                    └───────────────────┬──────────────────────────┘
                                        │
        ┌───────────────────────────────┼───────────────────────────────┐
        │                               │                               │
┌───────▼────────┐            ┌─────────▼─────────┐          ┌──────────▼─────────┐
│  WP App Node 1 │            │  WP App Node 2    │          │  WP App Node N     │
│  PHP-FPM 8.2   │            │  PHP-FPM 8.2      │          │  PHP-FPM 8.2       │
│  SallaBridge   │            │  SallaBridge      │          │  SallaBridge       │
└───────┬────────┘            └─────────┬─────────┘          └──────────┬─────────┘
        │                               │                               │
        └───────────────┬───────────────┴───────────────┬───────────────┘
                        │                               │
              ┌─────────▼─────────┐         ┌───────────▼──────────┐
              │   MySQL Primary   │         │   Redis Cluster      │
              │   (Writer)        │         │   - Object Cache     │
              │                   │         │   - Session          │
              │   MySQL Replica   │         │   - Rate Limiter     │
              │   (Read)          │         │   - Queue Backend    │
              └───────────────────┘         └───────────┬──────────┘
                                                        │
                                            ┌───────────▼──────────┐
                                            │   Worker Pool        │
                                            │   (Action Scheduler) │
                                            │   - Import workers   │
                                            │   - Sync workers     │
                                            │   - Lock workers     │
                                            │   - AI workers       │
                                            └───────────┬──────────┘
                                                        │
        ┌───────────────────────────────────────────────┼───────────────────────────┐
        │                       │                       │                           │
┌───────▼──────┐    ┌───────────▼──────┐    ┌──────────▼──────┐    ┌───────────────▼──┐
│ Salla API    │    │ AliExpress API   │    │ AI Providers    │    │ Payment Gateways │
│ OAuth2       │    │ Amazon PA-API    │    │ OpenAI          │    │ Stripe           │
│ Webhooks     │    │ Noon / CJ / ...  │    │ Anthropic       │    │ Moyasar / Tap    │
└──────────────┘    └──────────────────┘    │ Gemini          │    │ PayPal           │
                                             └─────────────────┘    └──────────────────┘

31.2 طبقات الكود (Layered Architecture)

text

┌─────────────────────────────────────────────────────┐
│ Presentation Layer                                   │
│ - REST Controllers                                   │
│ - Admin Views (React)                                │
│ - CLI Commands (WP-CLI)                              │
├─────────────────────────────────────────────────────┤
│ Application Layer                                    │
│ - Use Cases (ImportProduct, LockTenant, ...)        │
│ - DTOs, Events, Commands, Queries                    │
├─────────────────────────────────────────────────────┤
│ Domain Layer                                         │
│ - Entities (Tenant, Subscription, Product)          │
│ - Value Objects (Money, Quota, Period)              │
│ - Domain Events                                      │
├─────────────────────────────────────────────────────┤
│ Infrastructure Layer                                 │
│ - Repositories (MySQL)                               │
│ - Gateways (Salla, AliExpress, Stripe)              │
│ - Queue, Cache, Crypto, Logger                      │
└─────────────────────────────────────────────────────┘

31.3 Dependency Rule

  • Domain لا يعتمد على أي طبقة.
  • Application يعتمد على Domain فقط.
  • Infrastructure يعتمد على Domain + Application.
  • Presentation يعتمد على Application.

🧱 القسم 32: Domain Layer — الكيانات والقيم

32.1 Value Objects

php

namespace SallaBridge\Domain\ValueObject;

final class Money {
    public function __construct(
        private int $amount_cents,
        private string $currency
    ) {
        if ($amount_cents < 0) throw new \InvalidArgumentException('Negative money');
        if (!preg_match('/^[A-Z]{3}$/', $currency)) throw new \InvalidArgumentException('Bad currency');
    }

    public static function fromFloat(float $amount, string $currency): self {
        return new self((int) round($amount * 100), $currency);
    }

    public function add(self $other): self {
        $this->assert_same_currency($other);
        return new self($this->amount_cents + $other->amount_cents, $this->currency);
    }

    public function multiply(float $factor): self {
        return new self((int) round($this->amount_cents * $factor), $this->currency);
    }

    public function with_markup(float $percent): self {
        return $this->multiply(1 + $percent / 100);
    }

    public function to_float(): float { return $this->amount_cents / 100; }

    private function assert_same_currency(self $other): void {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Currency mismatch');
        }
    }
}

final class Quota {
    public function __construct(
        private string $feature_key,
        private int $limit,
        private int $used,
        private \DateTimeImmutable $period_start,
        private \DateTimeImmutable $period_end
    ) {}

    public function is_exhausted(): bool { return $this->used >= $this->limit; }
    public function remaining(): int { return max(0, $this->limit - $this->used); }
    public function can_consume(int $qty): bool { return $this->used + $qty <= $this->limit; }
    public function consume(int $qty): self {
        if (!$this->can_consume($qty)) throw new \DomainException('Quota exceeded');
        return new self($this->feature_key, $this->limit, $this->used + $qty,
                        $this->period_start, $this->period_end);
    }
    public function is_expired(\DateTimeImmutable $now): bool { return $now > $this->period_end; }
}

32.2 Entities

php

namespace SallaBridge\Domain\Entity;

final class Tenant {
    private array $events = [];

    public function __construct(
        private ?int $id,
        private int $user_id,
        private string $store_name,
        private ?string $salla_store_id,
        private TenantStatus $status,
        private ?\DateTimeImmutable $trial_ends_at
    ) {}

    public function activate(): void {
        if ($this->status === TenantStatus::ACTIVE) return;
        $this->status = TenantStatus::ACTIVE;
        $this->events[] = new TenantActivated($this->id);
    }

    public function mark_past_due(): void {
        $this->status = TenantStatus::PAST_DUE;
        $this->events[] = new TenantPastDue($this->id);
    }

    public function lock(string $reason): void {
        if ($this->status === TenantStatus::LOCKED) return;
        $this->status = TenantStatus::LOCKED;
        $this->events[] = new TenantLocked($this->id, $reason);
    }

    public function release(string $trigger): void {
        $this->status = TenantStatus::ACTIVE;
        $this->events[] = new TenantReleased($this->id, $trigger);
    }

    public function pull_events(): array {
        $e = $this->events; $this->events = []; return $e;
    }
}

enum TenantStatus: string {
    case TRIAL = 'trial';
    case ACTIVE = 'active';
    case PAST_DUE = 'past_due';
    case LOCKED = 'locked';
    case CANCELLED = 'cancelled';
}

32.3 Domain Events

php

namespace SallaBridge\Domain\Event;

final class TenantLocked {
    public function __construct(
        public readonly int $tenant_id,
        public readonly string $reason,
        public readonly \DateTimeImmutable $occurred_at = new \DateTimeImmutable()
    ) {}
}

final class SubscriptionRenewed {
    public function __construct(
        public readonly int $tenant_id,
        public readonly int $subscription_id,
        public readonly \DateTimeImmutable $new_period_end
    ) {}
}

final class ProductImported {
    public function __construct(
        public readonly int $tenant_id,
        public readonly string $source_product_id,
        public readonly string $salla_product_id
    ) {}
}

🗃️ القسم 33: Repository Pattern — الطبقة الكاملة

33.1 Base Repository

php

namespace SallaBridge\Core\Repositories;

abstract class BaseRepository {
    protected \wpdb $db;
    protected string $table;
    protected string $primary_key = 'id';

    public function __construct() {
        global $wpdb;
        $this->db = $wpdb;
    }

    protected function table(): string {
        return $this->db->prefix . $this->table;
    }

    public function find(int $id): ?object {
        $row = $this->db->get_row($this->db->prepare(
            "SELECT * FROM {$this->table()} WHERE {$this->primary_key} = %d", $id
        ));
        return $row ?: null;
    }

    public function find_by(string $column, mixed $value): ?object {
        $row = $this->db->get_row($this->db->prepare(
            "SELECT * FROM {$this->table()} WHERE {$column} = %s LIMIT 1",
            (string) $value
        ));
        return $row ?: null;
    }

    public function all(array $where = [], int $limit = 100, int $offset = 0): array {
        $sql = "SELECT * FROM {$this->table()}";
        if (!empty($where)) {
            $conditions = [];
            $values = [];
            foreach ($where as $col => $val) {
                $conditions[] = "{$col} = %s";
                $values[] = $val;
            }
            $sql .= ' WHERE ' . implode(' AND ', $conditions);
        }
        $sql .= ' LIMIT %d OFFSET %d';
        $values[] = $limit;
        $values[] = $offset;
        return $this->db->get_results($this->db->prepare($sql, ...$values));
    }

    public function insert(array $data): int {
        $this->db->insert($this->table(), $data);
        return (int) $this->db->insert_id;
    }

    public function update(int $id, array $data): bool {
        return (bool) $this->db->update($this->table(), $data, [$this->primary_key => $id]);
    }

    public function delete(int $id): bool {
        return (bool) $this->db->delete($this->table(), [$this->primary_key => $id]);
    }

    public function count(array $where = []): int {
        $sql = "SELECT COUNT(*) FROM {$this->table()}";
        if (!empty($where)) {
            $conditions = [];
            $values = [];
            foreach ($where as $col => $val) {
                $conditions[] = "{$col} = %s";
                $values[] = $val;
            }
            $sql .= ' WHERE ' . implode(' AND ', $conditions);
            return (int) $this->db->get_var($this->db->prepare($sql, ...$values));
        }
        return (int) $this->db->get_var($sql);
    }
}

33.2 ProductsMapRepo — مثال متكامل

php

final class ProductsMapRepo extends BaseRepository {
    protected string $table = 'sb_products_map';

    public function get_unlocked_ids(int $tenant_id, int $limit = 5000): array {
        return $this->db->get_col($this->db->prepare("
            SELECT salla_product_id FROM {$this->table()}
            WHERE tenant_id = %d AND locked = 0 AND salla_product_id IS NOT NULL
            LIMIT %d
        ", $tenant_id, $limit));
    }

    public function get_locked_ids(int $tenant_id, int $limit = 5000): array {
        return $this->db->get_col($this->db->prepare("
            SELECT salla_product_id FROM {$this->table()}
            WHERE tenant_id = %d AND locked = 1 AND salla_product_id IS NOT NULL
            LIMIT %d
        ", $tenant_id, $limit));
    }

    public function mark_locked(int $tenant_id, array $salla_ids): void {
        if (empty($salla_ids)) return;
        $placeholders = implode(',', array_fill(0, count($salla_ids), '%s'));
        $this->db->query($this->db->prepare("
            UPDATE {$this->table()} SET locked = 1, updated_at = NOW()
            WHERE tenant_id = %d AND salla_product_id IN ({$placeholders})
        ", $tenant_id, ...$salla_ids));
    }

    public function mark_unlocked(int $tenant_id, array $salla_ids): void {
        if (empty($salla_ids)) return;
        $placeholders = implode(',', array_fill(0, count($salla_ids), '%s'));
        $this->db->query($this->db->prepare("
            UPDATE {$this->table()} SET locked = 0, updated_at = NOW()
            WHERE tenant_id = %d AND salla_product_id IN ({$placeholders})
        ", $tenant_id, ...$salla_ids));
    }

    public function find_by_source(int $tenant_id, int $source_id, string $source_product_id): ?object {
        return $this->db->get_row($this->db->prepare("
            SELECT * FROM {$this->table()}
            WHERE tenant_id = %d AND source_id = %d AND source_product_id = %s
            LIMIT 1
        ", $tenant_id, $source_id, $source_product_id)) ?: null;
    }

    public function upsert(int $tenant_id, int $source_id, string $source_product_id, array $data): int {
        $existing = $this->find_by_source($tenant_id, $source_id, $source_product_id);
        $data['tenant_id'] = $tenant_id;
        $data['source_id'] = $source_id;
        $data['source_product_id'] = $source_product_id;
        if ($existing) {
            $this->update((int) $existing->id, $data);
            return (int) $existing->id;
        }
        return $this->insert($data);
    }

    public function get_stats(int $tenant_id): array {
        return (array) $this->db->get_row($this->db->prepare("
            SELECT
              COUNT(*) AS total,
              SUM(locked = 1) AS locked,
              SUM(sync_status = 'synced') AS synced,
              SUM(sync_status = 'failed') AS failed,
              SUM(image_count) AS total_images
            FROM {$this->table()} WHERE tenant_id = %d
        ", $tenant_id), ARRAY_A);
    }
}

🔗 القسم 34: Service Container & DI

34.1 Container

php

namespace SallaBridge\Core;

final class Container {
    private static array $bindings = [];
    private static array $instances = [];
    private static array $singletons = [];

    public static function bind(string $abstract, callable $factory, bool $singleton = true): void {
        self::$bindings[$abstract] = $factory;
        if ($singleton) self::$singletons[$abstract] = true;
    }

    public static function get(string $abstract): mixed {
        if (isset(self::$instances[$abstract])) return self::$instances[$abstract];
        if (!isset(self::$bindings[$abstract])) {
            throw new \RuntimeException("No binding for {$abstract}");
        }
        $instance = (self::$bindings[$abstract])(self::class);
        if (isset(self::$singletons[$abstract])) {
            self::$instances[$abstract] = $instance;
        }
        return $instance;
    }

    public static function set(string $abstract, mixed $instance): void {
        self::$instances[$abstract] = $instance;
    }

    public static function flush(): void {
        self::$instances = [];
    }
}

34.2 Bindings (في Plugin::register_bindings())

php

Container::bind(ProductRepo::class, fn() => new ProductsMapRepo());
Container::bind(TenantRepo::class, fn() => new TenantRepo());
Container::bind(SubscriptionRepo::class, fn() => new SubscriptionRepo());
Container::bind(PlanRepo::class, fn() => new PlanRepo());
Container::bind(SourceRepo::class, fn() => new SourceRepo());
Container::bind(Salla\Client::class, fn() => new Salla\Client());
Container::bind(Salla\SallaLocker::class, fn() => new Salla\SallaLocker(Container::get(Salla\Client::class)));
Container::bind(Locking\LockManager::class, fn() => new Locking\LockManager(
    Container::get(Salla\SallaLocker::class),
    Container::get(ProductsMapRepo::class),
    Container::get(CategoriesMapRepo::class),
    Container::get(Events\Dispatcher::class),
    Container::get(Support\Logger::class)
));
Container::bind(Billing\SubscriptionManager::class, fn() => new Billing\SubscriptionManager(
    Container::get(SubscriptionRepo::class),
    Container::get(PlanRepo::class),
    Container::get(Billing\EntitlementChecker::class),
    Container::get(Locking\LockManager::class)
));
Container::bind(AI\Gateway::class, fn() => new AI\Gateway(
    Container::get(AI\Providers\OpenAIProvider::class),
    Container::get(AI\Providers\AnthropicProvider::class),
    Container::get(AI\Providers\GeminiProvider::class)
));

🎬 القسم 35: Use Cases (Application Layer)

35.1 ImportProductUseCase

php

namespace SallaBridge\Application\UseCase;

final class ImportProductUseCase {
    public function __construct(
        private Sources\SourceInterface $source,
        private Salla\SallaClient $salla,
        private Salla\ProductPusher $pusher,
        private Salla\MediaUploader $media,
        private AI\Gateway $ai,
        private Billing\EntitlementChecker $entitlements,
        private ProductsMapRepo $products,
        private Support\Logger $logger,
        private Events\Dispatcher $events
    ) {}

    public function execute(ImportProductCommand $cmd): ImportResult {
        // 1. Entitlement check
        if (!$this->entitlements->can($cmd->tenant_id, 'import_products', 1)) {
            throw new EntitlementException('quota_exceeded');
        }

        // 2. Duplicate check
        $existing = $this->products->find_by_source(
            $cmd->tenant_id, $cmd->source_id, $cmd->source_product_id
        );
        if ($existing && !$cmd->force_update) {
            return ImportResult::duplicate($existing->salla_product_id);
        }

        // 3. Fetch from source
        $raw = $this->source->fetch_product($cmd->source_product_id);
        $dto = $this->source->normalize($raw);

        // 4. AI enrichment (optional)
        if ($cmd->use_ai) {
            $dto = $this->enrich_with_ai($dto, $cmd->tenant_id);
        }

        // 5. Apply markup
        $price = Money::fromFloat($dto->price, $dto->currency)
            ->with_markup($cmd->markup_percent)
            ->to_float();

        // 6. Push to Salla
        $salla_id = $this->pusher->create_or_update($cmd->tenant_id, [
            'name'        => $dto->title,
            'description' => $dto->description,
            'price'       => $price,
            'quantity'    => $dto->stock,
            'sku'         => $dto->sku,
            'status'      => 'sale',
            'categories'  => $cmd->salla_category_ids,
        ]);

        // 7. Upload images
        foreach ($dto->images as $i => $img) {
            $this->media->upload($cmd->tenant_id, $salla_id, $img['url'], $i);
        }

        // 8. Persist mapping
        $this->products->upsert($cmd->tenant_id, $cmd->source_id, $cmd->source_product_id, [
            'salla_product_id' => $salla_id,
            'title'            => $dto->title,
            'price_source'     => $dto->price,
            'price_salla'      => $price,
            'markup_percent'   => $cmd->markup_percent,
            'image_count'      => count($dto->images),
            'stock'            => $dto->stock,
            'sync_status'      => 'synced',
            'synced_at'        => current_time('mysql'),
        ]);

        // 9. Consume quota
        $this->entitlements->consume($cmd->tenant_id, 'import_products', 1);
        $this->entitlements->consume($cmd->tenant_id, 'images', count($dto->images));

        // 10. Emit event
        $this->events->dispatch(new ProductImported(
            $cmd->tenant_id, $cmd->source_product_id, $salla_id
        ));

        return ImportResult::success($salla_id);
    }

    private function enrich_with_ai(ProductDTO $dto, int $tenant_id): ProductDTO {
        $dto->title = $this->ai->translate($dto->title, 'ar');
        $dto->description = $this->ai->translate($dto->description, 'ar');
        return $dto;
    }
}

35.2 ImportProductCommand

php

final class ImportProductCommand {
    public function __construct(
        public readonly int $tenant_id,
        public readonly int $source_id,
        public readonly string $source_product_id,
        public readonly array $salla_category_ids = [],
        public readonly float $markup_percent = 0,
        public readonly bool $use_ai = true,
        public readonly bool $force_update = false
    ) {}
}

35.3 SubscribeTenantUseCase

php

final class SubscribeTenantUseCase {
    public function execute(
        int $tenant_id,
        int $plan_id,
        string $billing_cycle,
        string $gateway,
        array $payment_method
    ): Subscription {
        $plan = Container::get(PlanRepo::class)->find($plan_id);
        if (!$plan) throw new \RuntimeException('Plan not found');

        $amount = $billing_cycle === 'yearly' ? $plan->price_yearly : $plan->price_monthly;

        // 1. Create subscription record (pending)
        $sub_id = Container::get(SubscriptionRepo::class)->insert([
            'tenant_id'      => $tenant_id,
            'plan_id'        => $plan_id,
            'billing_cycle'  => $billing_cycle,
            'gateway'        => $gateway,
            'status'         => 'trialing',
            'amount'         => $amount,
            'currency'       => $plan->currency,
        ]);

        // 2. Charge via gateway
        $gateway_instance = GatewayFactory::make($gateway);
        $charge = $gateway_instance->charge($amount, $plan->currency, $payment_method);

        if (!$charge->success) {
            Container::get(SubscriptionRepo::class)->update($sub_id, ['status' => 'cancelled']);
            throw new PaymentFailedException($charge->error);
        }

        // 3. Activate
        $period_end = $billing_cycle === 'yearly'
            ? strtotime('+1 year')
            : strtotime('+1 month');

        Container::get(SubscriptionRepo::class)->update($sub_id, [
            'status'               => 'active',
            'gateway_sub_id'       => $charge->subscription_id,
            'gateway_customer_id'  => $charge->customer_id,
            'current_period_start' => current_time('mysql'),
            'current_period_end'   => date('Y-m-d H:i:s', $period_end),
        ]);

        // 4. Update tenant
        Container::get(TenantRepo::class)->update($tenant_id, [
            'plan_id' => $plan_id,
            'status'  => 'active',
        ]);

        // 5. Init usage counters
        Container::get(UsageRepo::class)->init_period($tenant_id, $plan_id);

        // 6. Release lock if any
        $lock = Container::get(LockManager::class);
        if ($lock->is_locked($tenant_id)) {
            $lock->release($tenant_id, 'new_subscription');
        }

        return Container::get(SubscriptionRepo::class)->find($sub_id);
    }
}

🌐 القسم 36: Source Adapter — AliExpress الكامل

php

namespace SallaBridge\Sources;

final class AliExpressSource implements SourceInterface {
    private const API_BASE = 'https://api-sg.aliexpress.com/sync';
    private const AUTH_BASE = 'https://api-sg.aliexpress.com/oauth/authorize';
    private const TOKEN_URL = 'https://api-sg.aliexpress.com/oauth/token';

    private string $app_key;
    private string $app_secret;
    private string $access_token;

    public function __construct(array $credentials) {
        $this->app_key      = $credentials['app_key'];
        $this->app_secret   = $credentials['app_secret'];
        $this->access_token = $credentials['access_token'] ?? '';
    }

    public function authenticate(array $credentials): bool {
        $res = wp_remote_post(self::TOKEN_URL, [
            'body' => [
                'grant_type'    => 'authorization_code',
                'client_id'     => $this->app_key,
                'client_secret' => $this->app_secret,
                'code'          => $credentials['code'],
                'redirect_uri'  => $credentials['redirect_uri'],
            ],
        ]);
        $body = json_decode(wp_remote_retrieve_body($res), true);
        if (empty($body['access_token'])) return false;
        $this->access_token = $body['access_token'];
        return true;
    }

    public function search(string $query, int $page = 1): array {
        $params = [
            'method'         => 'aliexpress.affiliate.product.query',
            'app_key'        => $this->app_key,
            'timestamp'      => (string) (time() * 1000),
            'format'         => 'json',
            'v'              => '2.0',
            'sign_method'    => 'sha256',
            'keywords'       => $query,
            'page_no'        => (string) $page,
            'page_size'      => '50',
            'target_currency'=> 'SAR',
            'target_language'=> 'ar',
            'tracking_id'    => 'sallabridge',
        ];
        $params['sign'] = $this->sign($params);

        $res = wp_remote_get(add_query_arg($params, self::API_BASE), ['timeout' => 30]);
        $body = json_decode(wp_remote_retrieve_body($res), true);

        return $body['aliexpress_affiliate_product_query_response']['resp_result']['result']['products']['product'] ?? [];
    }

    public function fetch_product(string $external_id): array {
        $params = [
            'method'      => 'aliexpress.affiliate.productdetail.get',
            'app_key'     => $this->app_key,
            'timestamp'   => (string) (time() * 1000),
            'format'      => 'json',
            'v'           => '2.0',
            'sign_method' => 'sha256',
            'product_ids' => $external_id,
            'target_currency' => 'SAR',
            'target_language' => 'ar',
            'tracking_id' => 'sallabridge',
        ];
        $params['sign'] = $this->sign($params);

        $res = wp_remote_get(add_query_arg($params, self::API_BASE), ['timeout' => 30]);
        $body = json_decode(wp_remote_retrieve_body($res), true);

        return $body['aliexpress_affiliate_productdetail_get_response']['resp_result']['result']['products']['product'][0] ?? [];
    }

    public function fetch_images(string $external_id): array {
        $product = $this->fetch_product($external_id);
        $images = [];
        if (!empty($product['product_main_image_url'])) {
            $images[] = ['url' => $product['product_main_image_url'], 'alt' => $product['product_title'] ?? ''];
        }
        foreach ($product['product_small_image_urls']['string'] ?? [] as $url) {
            $images[] = ['url' => $url, 'alt' => ''];
        }
        return $images;
    }

    public function fetch_categories(): array {
        // AliExpress categories are nested deep; simplified
        return [];
    }

    public function normalize(array $raw): ProductDTO {
        $dto = new ProductDTO();
        $dto->external_id  = (string) ($raw['product_id'] ?? '');
        $dto->title        = (string) ($raw['product_title'] ?? '');
        $dto->description  = (string) ($raw['product_detail'] ?? '');
        $dto->price        = (float) ($raw['target_sale_price'] ?? 0);
        $dto->currency     = (string) ($raw['target_sale_price_currency'] ?? 'SAR');
        $dto->stock        = (int) ($raw['product_stock'] ?? 0);
        $dto->sku          = (string) ($raw['product_id'] ?? '');
        $dto->category_path= (string) ($raw['second_level_category_name'] ?? '');
        $dto->images       = $this->fetch_images($dto->external_id);
        $dto->raw          = $raw;
        return $dto;
    }

    private function sign(array $params): string {
        ksort($params);
        $concatenated = '';
        foreach ($params as $k => $v) {
            $concatenated .= $k . $v;
        }
        return strtoupper(hash_hmac('sha256', $concatenated, $this->app_secret));
    }
}

💳 القسم 37: Payment Gateways — التنفيذ الكامل

37.1 Interface

php

namespace SallaBridge\Billing\Gateways;

interface GatewayInterface {
    public function charge(float $amount, string $currency, array $payment_method): ChargeResult;
    public function refund(string $charge_id, float $amount): bool;
    public function create_subscription(int $tenant_id, int $plan_id, string $cycle): SubscriptionResult;
    public function cancel_subscription(string $gateway_sub_id): bool;
    public function verify_webhook(string $raw, array $headers): bool;
    public function handle_webhook(array $payload): WebhookResult;
}

37.2 Moyasar (السعودي)

php

final class MoyasarGateway implements GatewayInterface {
    private const API_BASE = 'https://api.moyasar.com/v1';
    private const WEBHOOK_SECRET_OPTION = 'sb_moyasar_webhook_secret';

    public function __construct(private string $secret_key) {}

    public function charge(float $amount, string $currency, array $payment_method): ChargeResult {
        $res = wp_remote_post(self::API_BASE . '/payments', [
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode($this->secret_key . ':'),
                'Content-Type'  => 'application/json',
            ],
            'body' => wp_json_encode([
                'amount'      => (int) round($amount * 100),
                'currency'    => strtoupper($currency),
                'description' => 'SallaBridge Subscription',
                'source'      => $payment_method, // ['type'=>'creditcard','name'=>'','number'=>'','...']
                'callback_url'=> home_url('/wp-json/sb/v1/webhooks/moyasar'),
            ]),
        ]);

        $body = json_decode(wp_remote_retrieve_body($res), true);
        return new ChargeResult(
            success: ($body['status'] ?? '') === 'paid',
            charge_id: $body['id'] ?? '',
            subscription_id: $body['id'] ?? '',
            customer_id: '',
            error: $body['message'] ?? null,
            raw: $body
        );
    }

    public function refund(string $charge_id, float $amount): bool {
        $res = wp_remote_post(self::API_BASE . "/payments/{$charge_id}/refund", [
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode($this->secret_key . ':'),
                'Content-Type'  => 'application/json',
            ],
            'body' => wp_json_encode(['amount' => (int) round($amount * 100)]),
        ]);
        return wp_remote_retrieve_response_code($res) === 200;
    }

    public function verify_webhook(string $raw, array $headers): bool {
        $secret = get_option(self::WEBHOOK_SECRET_OPTION);
        $sig = $headers['x-moyasar-signature'] ?? '';
        $expected = base64_encode(hash_hmac('sha256', $raw, $secret, true));
        return hash_equals($expected, $sig);
    }

    public function handle_webhook(array $payload): WebhookResult {
        $type = $payload['type'] ?? '';
        $data = $payload['data'] ?? [];
        return new WebhookResult($type, $data);
    }

    // stubs
    public function create_subscription(int $t, int $p, string $c): SubscriptionResult {
        return new SubscriptionResult(true, '', '');
    }
    public function cancel_subscription(string $id): bool { return true; }
}

37.3 Gateway Factory

php

final class GatewayFactory {
    public static function make(string $gateway): GatewayInterface {
        return match ($gateway) {
            'stripe'  => new StripeGateway(get_option('sb_stripe_secret')),
            'paypal'  => new PaypalGateway(get_option('sb_paypal_client_id'), get_option('sb_paypal_secret')),
            'moyasar' => new MoyasarGateway(get_option('sb_moyasar_secret')),
            'tap'     => new TapGateway(get_option('sb_tap_secret')),
            default   => throw new \InvalidArgumentException("Unknown gateway: {$gateway}"),
        };
    }
}

🔌 القسم 38: REST Controllers الكاملة

38.1 Base Controller

php

namespace SallaBridge\Api;

abstract class BaseController {
    protected string $namespace = 'sb/v1';
    protected string $rest_base = '';

    public function register_routes(): void {
        register_rest_route($this->namespace, '/' . $this->rest_base, $this->routes());
    }

    abstract protected function routes(): array;

    protected function require_capability(string $cap): bool {
        return current_user_can($cap);
    }

    protected function current_tenant(): ?object {
        return Container::get('current_tenant');
    }

    protected function error(string $code, string $message, int $status = 400): \WP_Error {
        return new \WP_Error($code, $message, ['status' => $status]);
    }

    protected function ok(mixed $data, int $status = 200): \WP_REST_Response {
        return new \WP_REST_Response(['success' => true, 'data' => $data], $status);
    }
}

38.2 ImportController

php

final class ImportController extends BaseController {
    protected string $rest_base = 'import';

    protected function routes(): array {
        return [
            [
                'methods'  => 'POST',
                'route'    => '/start',
                'callback' => [$this, 'start'],
                'permission_callback' => fn() => $this->require_capability('sb_start_import'),
                'args' => [
                    'source_id'    => ['required' => true, 'type' => 'integer'],
                    'product_ids'  => ['required' => true, 'type' => 'array'],
                    'category_ids' => ['type' => 'array', 'default' => []],
                    'markup'       => ['type' => 'number', 'default' => 0],
                    'use_ai'       => ['type' => 'boolean', 'default' => true],
                ],
            ],
            [
                'methods'  => 'GET',
                'route'    => '/(?P<job_id>\d+)/status',
                'callback' => [$this, 'status'],
                'permission_callback' => fn() => $this->require_capability('sb_view_jobs'),
            ],
        ];
    }

    public function start(\WP_REST_Request $req): \WP_REST_Response|\WP_Error {
        $tenant = $this->current_tenant();
        if (!$tenant) return $this->error('no_tenant', 'Tenant not resolved', 401);

        // Lock check
        if (Container::get(LockManager::class)->is_locked($tenant->id)) {
            return $this->error('subscription_locked', 'Renew subscription', 403);
        }

        $entitlements = Container::get(EntitlementChecker::class);
        $product_ids = $req->get_param('product_ids');
        if (!$entitlements->can($tenant->id, 'import_products', count($product_ids))) {
            return $this->error('quota_exceeded', 'Not enough quota', 402);
        }

        // Create job
        $job_id = Container::get(ImportJobRepo::class)->insert([
            'tenant_id'       => $tenant->id,
            'source_id'       => $req->get_param('source_id'),
            'job_type'        => 'bulk',
            'status'          => 'queued',
            'total_items'     => count($product_ids),
            'processed_items' => 0,
            'payload_json'    => wp_json_encode([
                'product_ids'  => $product_ids,
                'category_ids' => $req->get_param('category_ids'),
                'markup'       => $req->get_param('markup'),
                'use_ai'       => $req->get_param('use_ai'),
            ]),
        ]);

        // Enqueue background job
        Queue::push('import.batch', [
            'job_id' => $job_id,
            'tenant_id' => $tenant->id,
        ]);

        return $this->ok(['job_id' => $job_id, 'status' => 'queued'], 202);
    }

    public function status(\WP_REST_Request $req): \WP_REST_Response|\WP_Error {
        $job = Container::get(ImportJobRepo::class)->find((int) $req['job_id']);
        if (!$job || $job->tenant_id !== $this->current_tenant()->id) {
            return $this->error('not_found', 'Job not found', 404);
        }
        return $this->ok([
            'id'        => (int) $job->id,
            'status'    => $job->status,
            'total'     => (int) $job->total_items,
            'processed' => (int) $job->processed_items,
            'failed'    => (int) $job->failed_items,
            'progress'  => $job->total_items > 0
                ? round(($job->processed_items / $job->total_items) * 100, 2)
                : 0,
        ]);
    }
}

38.3 BillingController

php

final class BillingController extends BaseController {
    protected string $rest_base = 'billing';

    protected function routes(): array {
        return [
            ['methods' => 'GET',  'route' => '/status',     'callback' => [$this, 'status'],
             'permission_callback' => fn() => $this->require_capability('sb_view_billing')],
            ['methods' => 'POST', 'route' => '/subscribe',  'callback' => [$this, 'subscribe'],
             'permission_callback' => fn() => $this->require_capability('sb_manage_billing')],
            ['methods' => 'POST', 'route' => '/cancel',     'callback' => [$this, 'cancel'],
             'permission_callback' => fn() => $this->require_capability('sb_manage_billing')],
            ['methods' => 'GET',  'route' => '/invoices',   'callback' => [$this, 'invoices'],
             'permission_callback' => fn() => $this->require_capability('sb_view_billing')],
        ];
    }

    public function status(\WP_REST_Request $req): \WP_REST_Response {
        $tenant = $this->current_tenant();
        $sub = Container::get(SubscriptionRepo::class)->find_by('tenant_id', $tenant->id);
        $plan = $sub ? Container::get(PlanRepo::class)->find($sub->plan_id) : null;
        $usage = Container::get(UsageRepo::class)->get_tenant_usage($tenant->id);

        return $this->ok([
            'tenant_status' => $tenant->status,
            'subscription'  => $sub,
            'plan'          => $plan,
            'usage'         => $usage,
            'days_remaining'=> $sub ? max(0, (int) ((strtotime($sub->current_period_end) - time()) / DAY_IN_SECONDS)) : 0,
        ]);
    }

    public function subscribe(\WP_REST_Request $req): \WP_REST_Response|\WP_Error {
        try {
            $use_case = Container::get(SubscribeTenantUseCase::class);
            $sub = $use_case->execute(
                tenant_id: $this->current_tenant()->id,
                plan_id: (int) $req->get_param('plan_id'),
                billing_cycle: $req->get_param('cycle'),
                gateway: $req->get_param('gateway'),
                payment_method: $req->get_param('payment_method')
            );
            return $this->ok($sub, 201);
        } catch (PaymentFailedException $e) {
            return $this->error('payment_failed', $e->getMessage(), 402);
        }
    }

    public function cancel(\WP_REST_Request $req): \WP_REST_Response {
        Container::get(SubscriptionManager::class)->cancel(
            $this->current_tenant()->id,
            $req->get_param('immediate') ?? false
        );
        return $this->ok(['cancelled' => true]);
    }

    public function invoices(\WP_REST_Request $req): \WP_REST_Response {
        return $this->ok(Container::get(InvoiceRepo::class)->for_tenant($this->current_tenant()->id));
    }
}

🔐 القسم 39: JWT Authentication & Middleware

39.1 JWT Service

php

namespace SallaBridge\Api\Auth;

final class JwtService {
    private const ALGO = 'HS256';

    public static function issue(int $user_id, int $ttl = 3600): string {
        $header = ['alg' => self::ALGO, 'typ' => 'JWT'];
        $payload = [
            'sub' => $user_id,
            'iat' => time(),
            'exp' => time() + $ttl,
            'iss' => home_url(),
        ];
        return self::encode($header, $payload);
    }

    public static function verify(string $token): ?array {
        $parts = explode('.', $token);
        if (count($parts) !== 3) return null;
        [$h, $p, $s] = $parts;
        $expected = self::sign("{$h}.{$p}");
        if (!hash_equals($expected, $s)) return null;
        $payload = json_decode(self::b64d($p), true);
        if (($payload['exp'] ?? 0) < time()) return null;
        return $payload;
    }

    private static function encode(array $h, array $p): string {
        $h64 = self::b64e(wp_json_encode($h));
        $p64 = self::b64e(wp_json_encode($p));
        $sig = self::sign("{$h64}.{$p64}");
        return "{$h64}.{$p64}.{$sig}";
    }

    private static function sign(string $data): string {
        return self::b64e(hash_hmac('sha256', $data, SB_JWT_SECRET, true));
    }

    private static function b64e(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); }
    private static function b64d(string $s): string { return base64_decode(strtr($s, '-_', '+/')); }
}

39.2 Tenant Middleware

php

final class TenantMiddleware {
    public static function register(): void {
        add_filter('rest_pre_dispatch', [self::class, 'resolve'], 10, 3);
    }

    public static function resolve($result, $server, $request) {
        if (strpos($request->get_route(), '/sb/v1/') === false) return $result;

        // Public endpoints
        $public = ['/sb/v1/auth/salla/callback', '/sb/v1/webhooks/'];
        foreach ($public as $p) {
            if (strpos($request->get_route(), $p) !== false) return $result;
        }

        $auth = $request->get_header('authorization');
        if (!$auth || !str_starts_with($auth, 'Bearer ')) {
            return new \WP_Error('unauthorized', 'Missing token', ['status' => 401]);
        }

        $payload = JwtService::verify(substr($auth, 7));
        if (!$payload) {
            return new \WP_Error('invalid_token', 'Invalid or expired token', ['status' => 401]);
        }

        wp_set_current_user($payload['sub']);

        $tenant = Container::get(TenantRepo::class)->find_by('user_id', $payload['sub']);
        if (!$tenant) {
            return new \WP_Error('no_tenant', 'Tenant not found', ['status' => 404]);
        }

        Container::set('current_tenant', $tenant);
        return $result;
    }
}

🎨 القسم 40: React Dashboard — البنية الكاملة

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

text

frontend/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.js
├── src/
│   ├── main.tsx
│   ├── App.tsx
│   ├── api/
│   │   ├── client.ts
│   │   ├── import.ts
│   │   ├── billing.ts
│   │   └── products.ts
│   ├── hooks/
│   │   ├── useTenant.ts
│   │   ├── useSubscription.ts
│   │   ├── useImportJob.ts
│   │   └── useEntitlements.ts
│   ├── components/
│   │   ├── Layout/
│   │   │   ├── Sidebar.tsx
│   │   │   ├── Topbar.tsx
│   │   │   └── Layout.tsx
│   │   ├── Dashboard/
│   │   │   ├── KpiCard.tsx
│   │   │   ├── SubscriptionBanner.tsx
│   │   │   ├── ImportChart.tsx
│   │   │   └── RecentJobs.tsx
│   │   ├── Import/
│   │   │   ├── SourceSelector.tsx
│   │   │   ├── ProductSearch.tsx
│   │   │   ├── ImportWizard.tsx
│   │   │   └── JobProgress.tsx
│   │   ├── Billing/
│   │   │   ├── PlanCard.tsx
│   │   │   ├── QuotaMeter.tsx
│   │   │   └── CheckoutForm.tsx
│   │   └── UI/
│   │       ├── Button.tsx
│   │       ├── Modal.tsx
│   │       ├── Table.tsx
│   │       └── Toast.tsx
│   ├── pages/
│   │   ├── DashboardPage.tsx
│   │   ├── ImportPage.tsx
│   │   ├── ProductsPage.tsx
│   │   ├── BillingPage.tsx
│   │   ├── SettingsPage.tsx
│   │   └── OnboardingPage.tsx
│   ├── store/
│   │   ├── tenant.ts
│   │   └── notifications.ts
│   ├── lib/
│   │   ├── format.ts
│   │   └── constants.ts
│   └── styles/
│       └── globals.css

40.2 API Client

typescript

// src/api/client.ts
const API_BASE = '/wp-json/sb/v1';

class ApiClient {
  private token: string | null = null;

  setToken(t: string) { this.token = t; localStorage.setItem('sb_token', t); }
  loadToken() { this.token = localStorage.getItem('sb_token'); }

  private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
    const res = await fetch(`${API_BASE}${path}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
        ...(options.headers || {}),
      },
    });

    const json = await res.json();
    if (!res.ok) {
      if (res.status === 401) window.location.href = '/wp-login.php';
      if (res.status === 403 && json.code === 'subscription_locked') {
        window.dispatchEvent(new CustomEvent('sb:locked'));
      }
      throw new ApiError(json.message, json.code, res.status);
    }
    return json.data;
  }

  get<T>(path: string) { return this.request<T>(path); }
  post<T>(path: string, body: any) { return this.request<T>(path, { method: 'POST', body: JSON.stringify(body) }); }
  put<T>(path: string, body: any) { return this.request<T>(path, { method: 'PUT', body: JSON.stringify(body) }); }
}

export const api = new ApiClient();

40.3 useSubscription Hook

typescript

// src/hooks/useSubscription.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';

export interface SubscriptionStatus {
  tenant_status: string;
  subscription: {
    id: number;
    status: string;
    current_period_end: string;
    billing_cycle: 'monthly' | 'yearly';
  } | null;
  plan: {
    id: number;
    name: string;
    max_products: number;
    max_images: number;
    max_ai_tokens: number;
  } | null;
  usage: Record<string, { used: number; limit: number }>;
  days_remaining: number;
}

export function useSubscription() {
  return useQuery({
    queryKey: ['subscription'],
    queryFn: () => api.get<SubscriptionStatus>('/billing/status'),
    refetchInterval: 60_000,
  });
}

40.4 SubscriptionBanner Component

tsx

// src/components/Dashboard/SubscriptionBanner.tsx
import { useSubscription } from '../../hooks/useSubscription';

export function SubscriptionBanner() {
  const { data, isLoading } = useSubscription();
  if (isLoading || !data) return null;

  const { days_remaining, tenant_status } = data;

  if (tenant_status === 'locked') {
    return (
      <div className="bg-red-50 border-r-4 border-red-500 p-4 rounded-lg">
        <div className="flex items-center justify-between">
          <div>
            <h3 className="font-bold text-red-900">تم إيقاف متجرك مؤقتًا</h3>
            <p className="text-red-700 text-sm">
              اشتراكك منتهي. جدّد الآن لإعادة تفعيل منتجاتك تلقائيًا.
            </p>
          </div>
          <a href="/billing" className="bg-red-600 text-white px-4 py-2 rounded">
            جدّد الآن
          </a>
        </div>
      </div>
    );
  }

  if (days_remaining <= 7) {
    return (
      <div className="bg-amber-50 border-r-4 border-amber-500 p-4 rounded-lg">
        <div className="flex items-center justify-between">
          <div>
            <h3 className="font-bold text-amber-900">
              يتبقى {days_remaining} يوم على انتهاء اشتراكك
            </h3>
            <p className="text-amber-700 text-sm">
              جدّد الآن لتجنب إيقاف منتجاتك.
            </p>
          </div>
          <a href="/billing" className="bg-amber-600 text-white px-4 py-2 rounded">
            إدارة الاشتراك
          </a>
        </div>
      </div>
    );
  }

  return null;
}

40.5 ImportWizard (4 خطوات)

tsx

// src/components/Import/ImportWizard.tsx
import { useState } from 'react';
import { SourceSelector } from './SourceSelector';
import { ProductSearch } from './ProductSearch';
import { CategoryMapper } from './CategoryMapper';
import { ImportSummary } from './ImportSummary';
import { api } from '../../api/client';

const STEPS = ['اختر المصدر', 'ابحث واختر المنتجات', 'طابق التصنيفات', 'ملخص وتأكيد'];

export function ImportWizard({ onComplete }: { onComplete: (jobId: number) => void }) {
  const [step, setStep] = useState(0);
  const [state, setState] = useState({
    sourceId: 0,
    products: [] as string[],
    categories: [] as number[],
    markup: 20,
    useAi: true,
  });

  const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
  const back = () => setStep(s => Math.max(s - 1, 0));

  const submit = async () => {
    const { job_id } = await api.post<{ job_id: number }>('/import/start', {
      source_id: state.sourceId,
      product_ids: state.products,
      category_ids: state.categories,
      markup: state.markup,
      use_ai: state.useAi,
    });
    onComplete(job_id);
  };

  return (
    <div className="max-w-4xl mx-auto">
      <div className="flex justify-between mb-8">
        {STEPS.map((label, i) => (
          <div key={i} className={`flex-1 text-center ${i <= step ? 'text-blue-600' : 'text-gray-400'}`}>
            <div className={`w-8 h-8 mx-auto rounded-full flex items-center justify-center ${
              i < step ? 'bg-blue-600 text-white' :
              i === step ? 'bg-blue-100 border-2 border-blue-600 text-blue-600' :
              'bg-gray-100'
            }`}>
              {i < step ? '✓' : i + 1}
            </div>
            <span className="text-xs mt-2 block">{label}</span>
          </div>
        ))}
      </div>

      {step === 0 && <SourceSelector value={state.sourceId} onChange={id => setState(s => ({ ...s, sourceId: id }))} onNext={next} />}
      {step === 1 && <ProductSearch sourceId={state.sourceId} selected={state.products} onChange={p => setState(s => ({ ...s, products: p }))} onNext={next} onBack={back} />}
      {step === 2 && <CategoryMapper selected={state.categories} onChange={c => setState(s => ({ ...s, categories: c }))} onNext={next} onBack={back} />}
      {step === 3 && <ImportSummary state={state} onChange={setState} onSubmit={submit} onBack={back} />}
    </div>
  );
}

🧪 القسم 41: Test Factories & Fixtures

php

namespace SallaBridge\Tests\Factories;

final class TenantFactory {
    public static function create(array $overrides = []): object {
        global $wpdb;
        $user_id = wp_insert_user([
            'user_login' => 'tenant_' . wp_generate_password(6, false),
            'user_email' => wp_generate_password(6, false) . '@test.local',
            'user_pass'  => wp_generate_password(12),
            'role'       => 'sb_tenant_owner',
        ]);

        $data = array_merge([
            'user_id'    => $user_id,
            'store_name' => 'Test Store',
            'status'     => 'active',
            'locale'     => 'ar',
            'created_at' => current_time('mysql'),
        ], $overrides);

        $wpdb->insert($wpdb->prefix . 'sb_tenants', $data);
        $data['id'] = $wpdb->insert_id;
        return (object) $data;
    }
}

final class SubscriptionFactory {
    public static function create(int $tenant_id, array $overrides = []): object {
        global $wpdb;
        $data = array_merge([
            'tenant_id'            => $tenant_id,
            'plan_id'              => 1,
            'billing_cycle'        => 'monthly',
            'gateway'              => 'stripe',
            'status'               => 'active',
            'amount'               => 99,
            'currency'             => 'SAR',
            'current_period_start' => current_time('mysql'),
            'current_period_end'   => date('Y-m-d H:i:s', strtotime('+30 days')),
        ], $overrides);
        $wpdb->insert($wpdb->prefix . 'sb_subscriptions', $data);
        $data['id'] = $wpdb->insert_id;
        return (object) $data;
    }
}

📖 القسم 42: سيناريوهات حقيقية End-to-End

السيناريو 1: عميل جديد — من التسجيل إلى أول استيراد

text

[T+0s]   زيارة /salla-bridge/signup
[T+10s]  إنشاء حساب WP + tenant (trial 14 يوم)
[T+15s]  إعادة توجيه إلى Onboarding Wizard
[T+20s]  اختيار باقة Growth
[T+30s]  النقر على "ربط سلة" → OAuth flow
[T+45s]  موافقة في سلة → callback → تخزين tokens
[T+50s]  العودة للـWizard → اختيار مصدر AliExpress
[T+60s]  إدخال AppKey/Secret → حفظ مُشفّر
[T+70s]  البحث: "wireless earbuds" → 50 نتيجة
[T+90s]  اختيار 20 منتج → طابق التصنيفات → تأكيد
[T+92s]  Job queued → worker يبدأ
[T+120s] كل المنتجات في سلة مع الصور
[T+130s] إشعار بريد: "تم استيراد 20 منتجًا بنجاح"

السيناريو 2: انتهاء الاشتراك — القفل التلقائي

text

[Day 0]   الاشتراك active، current_period_end = Day 30
[Day 30]  Cron hourly يفحص → past_due → إيميل تنبيه #1
[Day 30]  محاولة تحصيل تلقائية عبر gateway
[Day 31]  فشل التحصيل → past_due → إيميل تنبيه #2
[Day 33]  فشل التحصيل → past_due → إيميل نهائي
[Day 33]  Cron: current_period_end + 3 days < now
[Day 33]  LockManager::lock(tenant, 'expired')
[Day 33]  50 منتجًا → PUT /products/{id} {status:'hidden'}
[Day 33]  8 تصنيفات → hidden
[Day 33]  locked = 1 في DB
[Day 33]  إيميل: "تم إيقاف متجرك مؤقتًا"
[Day 33]  كل المنتجات مخفية في سلة فورًا

السيناريو 3: التجديد — رفع القفل

text

[Day 35]  العميل يدفع عبر Moyasar
[Day 35]  Webhook: payment.paid → signature verified
[Day 35]  SubscriptionManager::renew()
[Day 35]  current_period_end = +30 days، status = active
[Day 35]  LockManager::release(tenant, 'renewal')
[Day 35]  50 منتج → PUT /products/{id} {status:'sale'}
[Day 35]  locked = 0
[Day 35]  إيميل: "تم تجديد اشتراكك — منتجاتك عادت"

السيناريو 4: مزامنة الأسعار اليومية

text

[03:00]   Cron: sb_daily_price_sync
[03:01]   جلب كل tenants active
[03:02]   لكل tenant: جلب products_map
[03:03]   استدعاء AliExpress بأسعار محدّثة
[03:04]   مقارنة: إذا تغيّر السعر > 2% → update Salla
[03:05]   تحديث price_source و price_salla في DB
[03:06]   تسجيل النتائج في audit_log
[03:07]   إنشاء تقرير يومي وإرساله بالبريد

🛠️ القسم 43: WP-CLI Commands

php

namespace SallaBridge\Cli;

final class Commands {
    public static function register(): void {
        if (!defined('WP_CLI')) return;

        \WP_CLI::add_command('sb install', [self::class, 'install']);
        \WP_CLI::add_command('sb tenant', [self::class, 'tenant']);
        \WP_CLI::add_command('sb lock', [self::class, 'lock']);
        \WP_CLI::add_command('sb release', [self::class, 'release']);
        \WP_CLI::add_command('sb sync', [self::class, 'sync']);
        \WP_CLI::add_command('sb check-subscriptions', [self::class, 'check_subs']);
        \WP_CLI::add_command('sb queue', [self::class, 'queue']);
    }

    public static function install(): void {
        \WP_CLI::log('Installing SallaBridge...');
        Core\Installer::install();
        Roles\RoleManager::register();
        \WP_CLI::success('Installation complete');
    }

    public static function tenant(array $args, array $assoc): void {
        $action = $args[0] ?? 'list';
        switch ($action) {
            case 'list':
                $tenants = Container::get(TenantRepo::class)->all([], 100);
                \WP_CLI\Utils\format_items('table', $tenants, ['id', 'store_name', 'status', 'plan_id']);
                break;
            case 'create':
                $id = Container::get(TenantRepo::class)->insert([
                    'user_id'    => $assoc['user_id'],
                    'store_name' => $assoc['name'],
                    'status'     => 'trial',
                ]);
                \WP_CLI::success("Tenant created: {$id}");
                break;
        }
    }

    public static function lock(array $args): void {
        $tenant_id = (int) $args[0];
        $count = Container::get(LockManager::class)->lock($tenant_id, 'manual_cli');
        \WP_CLI::success("Locked {$count} products");
    }

    public static function release(array $args): void {
        $tenant_id = (int) $args[0];
        $count = Container::get(LockManager::class)->release($tenant_id, 'manual_cli');
        \WP_CLI::success("Released {$count} products");
    }

    public static function check_subs(): void {
        \WP_CLI::log('Checking subscriptions...');
        Cron\CheckSubscriptions::run();
        \WP_CLI::success('Done');
    }

    public static function queue(array $args, array $assoc): void {
        $action = $args[0] ?? 'stats';
        switch ($action) {
            case 'stats':
                $count = as_get_scheduled_actions(['status' => 'pending'], 'ids');
                \WP_CLI::line('Pending jobs: ' . count($count));
                break;
            case 'run':
                as_run_queue();
                \WP_CLI::success('Queue processed');
                break;
        }
    }

    public static function sync(array $args, array $assoc): void {
        $tenant_id = (int) ($assoc['tenant'] ?? 0);
        if (!$tenant_id) {
            \WP_CLI::error('--tenant is required');
        }
        Queue::push('sync.prices', ['tenant_id' => $tenant_id]);
        \WP_CLI::success('Sync job queued');
    }
}

أمثلة استخدام:

bash

wp sb install
wp sb tenant list
wp sb tenant create --user_id=5 --name="متجر الأحذية"
wp sb lock 12
wp sb release 12
wp sb sync --tenant=12
wp sb check-subscriptions
wp sb queue stats

🔔 القسم 44: نظام الإشعارات

44.1 Notification Service

php

namespace SallaBridge\Notifications;

final class Notifier {
    private array $channels;

    public function __construct() {
        $this->channels = [
            'email'    => new Channels\EmailChannel(),
            'dashboard'=> new Channels\DashboardChannel(),
            'whatsapp' => new Channels\WhatsAppChannel(),
        ];
    }

    public function send(int $tenant_id, string $template, array $data = []): void {
        $prefs = get_option("sb_notif_prefs_{$tenant_id}", ['email' => true, 'dashboard' => true]);

        foreach ($prefs as $channel => $enabled) {
            if ($enabled && isset($this->channels[$channel])) {
                $this->channels[$channel]->send($tenant_id, $template, $data);
            }
        }
    }
}

44.2 Email Templates

Template Keyالحدثالمتغيرات
welcomeتسجيل جديد{store_name}, {trial_days}
subscription_renewedتجديد{plan}, {period_end}
subscription_past_dueفشل دفع{days_grace}, {update_url}
tenant_lockedقفل{products_count}, {renew_url}
tenant_releasedرفع قفل{products_count}
import_completedاكتمال استيراد{job_id}, {count}, {failed}
quota_warning80% من الحصة{feature}, {remaining}

44.3 WhatsApp Channel (اختياري v1.2)

php

final class WhatsAppChannel {
    public function send(int $tenant_id, string $template, array $data): void {
        $phone = get_user_meta(get_user_by('id', Container::get(TenantRepo::class)->find($tenant_id)->user_id)->ID, 'whatsapp', true);
        if (!$phone) return;

        $api_key = get_option('sb_whatsapp_api_key');
        $provider = get_option('sb_whatsapp_provider', 'twilio');

        // Twilio / Meta Cloud API / Unifonic
        wp_remote_post("https://api.twilio.com/2010-04-01/Accounts/{$sid}/Messages.json", [
            'headers' => ['Authorization' => 'Basic ' . base64_encode("{$sid}:{$token}")],
            'body' => [
                'From' => 'whatsapp:' . get_option('sb_whatsapp_from'),
                'To'   => "whatsapp:{$phone}",
                'Body' => $this->render($template, $data),
            ],
        ]);
    }
}

📊 القسم 45: Dashboards & Analytics

45.1 KPI Queries (Materialized Views)

sql

CREATE TABLE {$prefix}sb_tenant_stats (
  tenant_id BIGINT UNSIGNED NOT NULL,
  stat_date DATE NOT NULL,
  products_imported INT UNSIGNED NOT NULL DEFAULT 0,
  products_synced INT UNSIGNED NOT NULL DEFAULT 0,
  images_uploaded INT UNSIGNED NOT NULL DEFAULT 0,
  ai_tokens_used INT UNSIGNED NOT NULL DEFAULT 0,
  revenue DECIMAL(12,2) NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, stat_date),
  KEY idx_date (stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

45.2 Cron: sb_daily_stats (يوميًا 02:00)

php

final class DailyStats {
    public static function run(): void {
        global $wpdb;
        $yesterday = date('Y-m-d', strtotime('-1 day'));
        $tenants = $wpdb->get_col("SELECT id FROM {$wpdb->prefix}sb_tenants");

        foreach ($tenants as $tid) {
            $imported = $wpdb->get_var($wpdb->prepare("
                SELECT COUNT(*) FROM {$wpdb->prefix}sb_products_map
                WHERE tenant_id = %d AND DATE(created_at) = %s
            ", $tid, $yesterday));

            $wpdb->replace($wpdb->prefix . 'sb_tenant_stats', [
                'tenant_id'         => $tid,
                'stat_date'         => $yesterday,
                'products_imported' => (int) $imported,
            ]);
        }
    }
}

45.3 Dashboard API

php

// GET /sb/v1/dashboard/stats
public function stats(\WP_REST_Request $req): \WP_REST_Response {
    $tenant_id = $this->current_tenant()->id;
    $days = (int) ($req->get_param('days') ?? 30);

    global $wpdb;
    $series = $wpdb->get_results($wpdb->prepare("
        SELECT stat_date, products_imported, ai_tokens_used
        FROM {$wpdb->prefix}sb_tenant_stats
        WHERE tenant_id = %d AND stat_date >= DATE_SUB(NOW(), INTERVAL %d DAY)
        ORDER BY stat_date ASC
    ", $tenant_id, $days));

    $kpis = Container::get(ProductsMapRepo::class)->get_stats($tenant_id);

    return $this->ok([
        'series'    => $series,
        'kpis'      => $kpis,
        'today'     => [
            'imports'  => $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->prefix}sb_import_jobs WHERE tenant_id = %d AND DATE(created_at) = CURDATE()",
                $tenant_id
            )),
            'locked'   => (int) ($kpis['locked'] ?? 0),
        ],
    ]);
}

🎯 القسم 46: معالجة الحالات الحرجة (Edge Cases)

الحالةالحل
فشل رفع صورة واحدة من 50retry 3x ثم skip، تسجيل في DLQ، إشعار جزئي
تعطّل API المصدر مؤقتًاCircuit breaker + retry + fallback source
تعارض SKU مع منتج موجودAuto-suffix -dup-{n} + تسجيل
تجاوز حد Salla rate limitBackoff + queue delay
DB down خلال Locktransaction rollback + retry كامل
فشل webhook signaturelog + reject + alert admin
تعارض تزامن (Two workers same product)DB row-level lock SELECT ... FOR UPDATE
مستأجر يرفع 100K منتجchunk 50 + progress + timeout 30 دقيقة/دفعة
تغيّر سعر صرف العملةcache daily + tolerance 2%
مشكلة CORSheaders في rest_pre_serve_request

46.1 مثال: معالجة فشل جزئي

php

public function batch_import(array $ids, ImportProductCommand $cmd): array {
    $results = ['success' => 0, 'failed' => 0, 'errors' => []];

    foreach ($ids as $id) {
        try {
            $cmd->source_product_id = $id;
            $this->import_one($cmd);
            $results['success']++;
        } catch (\Throwable $e) {
            $results['failed']++;
            $results['errors'][] = ['id' => $id, 'error' => $e->getMessage()];

            // لا نوقف العملية بسبب منتج واحد
            Container::get(Logger::class)->error("Import failed for {$id}", [
                'tenant' => $cmd->tenant_id,
                'error'  => $e->getMessage(),
            ]);
        }
    }
    return $results;
}

🔒 القسم 47: GDPR & Data Privacy

47.1 حق المستخدم

الحقالتنفيذ
الوصول (Art. 15)REST /gdpr/export → JSON كامل
الحذف (Art. 17)TenantEraser → soft delete + queue wipe بعد 30 يوم
التصحيح (Art. 16)تعديل مباشر عبر Dashboard
قابلية النقل (Art. 20)CSV/JSON export
سحب الموافقةToggle في Settings

47.2 Data Retention Policy

البياناتالمدةبعدها
Audit logs24 شهرحذف تلقائي
API request logs90 يومحذف
AI cache180 يومحذف
Invoices7 سنواتاحتفاظ قانوني
بيانات tenant ملغى30 يومحذف نهائي

47.3 WordPress Hooks

php

add_filter('wp_privacy_personal_data_exporters', function ($exporters) {
    $exporters['salla-bridge'] = [
        'exporter_friendly_name' => 'SallaBridge Data',
        'callback' => [Gdpr::class, 'export'],
    ];
    return $exporters;
});

add_filter('wp_privacy_personal_data_erasers', function ($erasers) {
    $erasers['salla-bridge'] = [
        'eraser_friendly_name' => 'SallaBridge Eraser',
        'callback' => [Gdpr::class, 'erase'],
    ];
    return $erasers;
});

📦 القسم 48: التغليف والنشر (Packaging)

48.1 composer.json

json

{
  "name": "sallabridge/plugin",
  "description": "Salla Bridge — Product Import & Sync Platform",
  "type": "wordpress-plugin",
  "license": "proprietary",
  "require": {
    "php": ">=8.2",
    "ext-json": "*",
    "ext-sodium": "*",
    "ext-curl": "*",
    "guzzlehttp/guzzle": "^7.8",
    "firebase/php-jwt": "^6.10",
    "dompdf/dompdf": "^2.0"
  },
  "require-dev": {
    "phpunit/phpunit": "^10.0",
    "brain/monkey": "^2.6",
    "wp-phpunit/wp-phpunit": "^6.4",
    "squizlabs/php_codesniffer": "^3.8",
    "phpstan/phpstan": "^1.10",
    "szepeviktor/phpstan-wordpress": "^1.3"
  },
  "autoload": {
    "psr-4": { "SallaBridge\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "SallaBridge\\Tests\\": "tests/" }
  },
  "scripts": {
    "test": "phpunit",
    "lint": "phpcs --standard=PSR12 src/",
    "phpstan": "phpstan analyse src/ --level=6",
    "build": "composer install --no-dev --optimize-autoloader && npm ci && npm run build"
  }
}

48.2 salla-bridge.php (Main)

php

<?php
/**
 * Plugin Name:       Salla Bridge
 * Plugin URI:        https://sallabridge.com
 * Description:       منصة استيراد ومزامنة المنتجات من مصادر عالمية إلى متجر سلة
 * Version:           1.0.0
 * Requires at least: 6.2
 * Requires PHP:      8.2
 * Author:            SallaBridge
 * License:           GPL-2.0-or-later
 * Text Domain:       salla-bridge
 * Domain Path:       /languages
 */

declare(strict_types=1);

if (!defined('ABSPATH')) exit;

define('SB_VERSION', '1.0.0');
define('SB_FILE', __FILE__);
define('SB_DIR', plugin_dir_path(__FILE__));
define('SB_URL', plugin_dir_url(__FILE__));
define('SB_JWT_SECRET', defined('SB_JWT_SECRET_KEY') ? SB_JWT_SECRET_KEY : wp_salt('sb_jwt'));
define('SB_SALLA_CLIENT_ID', get_option('sb_salla_client_id', ''));
define('SB_SALLA_CLIENT_SECRET', get_option('sb_salla_client_secret', ''));

require_once SB_DIR . 'vendor/autoload.php';

register_activation_hook(__FILE__, ['\\SallaBridge\\Core\\Activator', 'activate']);
register_deactivation_hook(__FILE__, ['\\SallaBridge\\Core\\Deactivator', 'deactivate']);
register_uninstall_hook(__FILE__, ['\\SallaBridge\\Core\\Uninstaller', 'uninstall']);

add_action('plugins_loaded', function () {
    \SallaBridge\Core\Plugin::instance()->boot();
}, 5);

48.3 Plugin::boot()

php

final class Plugin {
    private static ?self $instance = null;

    public static function instance(): self {
        return self::$instance ??= new self();
    }

    public function boot(): void {
        // 1. Load textdomain
        load_plugin_textdomain('salla-bridge', false, dirname(plugin_basename(SB_FILE)) . '/languages');

        // 2. Boot crypto
        Support\Crypto::boot();

        // 3. Register bindings
        $this->register_bindings();

        // 4. Run migrations if needed
        if (get_option('sb_db_version') !== SB_VERSION) {
            Core\Installer::install();
            update_option('sb_db_version', SB_VERSION);
        }

        // 5. Register hooks
        $this->register_hooks();

        // 6. Boot components
        Api\Router::register();
        Roles\RoleManager::register();
        Cron\CheckSubscriptions::register();
        Cron\DailyStats::register();
        Queue::register();
        Notifications\Notifier::register();

        // 7. Admin
        if (is_admin()) {
            Admin\Menu::register();
            Admin\Assets::register();
        }

        // 8. WP-CLI
        if (defined('WP_CLI') && WP_CLI) {
            Cli\Commands::register();
        }

        // 9. Webhooks
        Webhooks\Handler::register();

        do_action('sb_booted');
    }

    private function register_hooks(): void {
        add_action('rest_api_init', [Api\TenantMiddleware::class, 'register']);
        add_action('woocommerce_payment_complete', [Billing\WooCommerceHooks::class, 'on_payment_complete']);
        add_action('woocommerce_order_status_refunded', [Billing\WooCommerceHooks::class, 'on_refund']);
    }

    private function register_bindings(): void {
        // ... as in section 34
    }
}

48.4 Build Script (bin/build.sh)

bash

#!/usr/bin/env bash
set -euo pipefail

VERSION=$(grep "Version:" salla-bridge.php | awk '{print $NF}')
SLUG="salla-bridge"
BUILD_DIR="build/${SLUG}"

echo "🔨 Building ${SLUG} v${VERSION}..."

# 1. Clean
rm -rf build
mkdir -p "${BUILD_DIR}"

# 2. Composer production
composer install --no-dev --optimize-autoloader --classmap-authoritative

# 3. Frontend
npm ci
npm run build

# 4. Copy files
rsync -av --exclude-from='.distignore' ./ "${BUILD_DIR}/"

# 5. Remove dev files
rm -rf "${BUILD_DIR}/node_modules" "${BUILD_DIR}/tests" "${BUILD_DIR}/.git"

# 6. Create zip
cd build && zip -r "${SLUG}-${VERSION}.zip" "${SLUG}"

echo "✅ Built: build/${SLUG}-${VERSION}.zip"

48.5 .distignore

text

.git
.github
node_modules
tests
build
*.md
!README.md
composer.json
composer.lock
phpunit.xml
phpstan.neon
.distignore
.gitignore
.editorconfig

📋 القسم 49: Checklist الإطلاق (Go-Live)

49.1 قبل الإطلاق

  • □ جميع الاختبارات تمر (Unit + Integration + E2E)
  • □ PHPStan level 6 بدون أخطاء
  • □ PHPCS PSR-12 نظيف
  • □ Coverage ≥ 80%
  • □ اختبار حمل: 1000 مستأجر متزامن
  • □ اختبار استعادة نسخة احتياطية
  • □ Penetration test (OWASP Top 10)
  • □ GDPR/PDPL compliance مراجعة
  • □ SSL/TLS 1.3
  • □ Secrets في مكان آمن
  • □ Sentry/Logging مفعّل
  • □ Monitoring + Alerts
  • □ Documentation محدّثة
  • □ صفحة Terms + Privacy
  • □ Onboarding videos جاهزة
  • □ Support channels مفعّلة

49.2 يوم الإطلاق

  • □ Deploy v1.0.0
  • □ تفعيل flag الاستيراد
  • □ مراقبة metrics لمدة 24 ساعة
  • □ فريق دعم في وضع الاستعداد
  • □ Rollback plan جاهز

49.3 بعد الإطلاق (أسبوع 1)

  • □ جمع feedback من أول 10 عملاء
  • □ مراقبة الـchurn
  • □ تحسين Onboarding حسب البيانات
  • □ إصلاح أي bug حرج خلال 24 ساعة
  • □ نشر changelog شفاف

🚀 القسم 50: الخطوة التالية الفورية

لديك الآن المواصفة الكاملة v1.0 + v1.1 + v1.2 بكل ما تحتاجه:

خيارات البدء:

  1. ابدأ بالكود الكامل لملف واحد بعينه — أخبرني أي ملف (مثلًا: Installer.php, LockManager.php, Salla\Client.php, ImportWizard.tsx).
  2. ابدأ بالـSQL فقط لتشغيل قاعدة البيانات فورًا.
  3. ابدأ بالـReact Dashboard كاملًا مع الـcomponents.
  4. ابدأ بسيناريو اختبار E2E من الصفر إلى القفل.
  5. احصل على OpenAPI 3.0 spec كامل لكل REST endpoints.
  6. احصل على خطة Sprint-by-Sprint لمدة 8 أسابيع مع توزيع المهام.

سؤال حاسم لك:

ما هو أول ملف تريد أن أُنتج لك كودًا كاملًا قابلًا للنسخ والتشغيل الآن؟

اختر رقمًا من الخيارات أعلاه، أو اكتب اسم الملف، وسأبدأ فورًا في كتابة الكود الإنتاجي بلا pseudo-code.

Scroll to Top

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