📊 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_warning | 80% من الحصة | {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)
| الحالة | الحل |
|---|---|
| فشل رفع صورة واحدة من 50 | retry 3x ثم skip، تسجيل في DLQ، إشعار جزئي |
| تعطّل API المصدر مؤقتًا | Circuit breaker + retry + fallback source |
| تعارض SKU مع منتج موجود | Auto-suffix -dup-{n} + تسجيل |
| تجاوز حد Salla rate limit | Backoff + queue delay |
| DB down خلال Lock | transaction rollback + retry كامل |
| فشل webhook signature | log + reject + alert admin |
| تعارض تزامن (Two workers same product) | DB row-level lock SELECT ... FOR UPDATE |
| مستأجر يرفع 100K منتج | chunk 50 + progress + timeout 30 دقيقة/دفعة |
| تغيّر سعر صرف العملة | cache daily + tolerance 2% |
| مشكلة CORS | headers في 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 logs | 24 شهر | حذف تلقائي |
| API request logs | 90 يوم | حذف |
| AI cache | 180 يوم | حذف |
| Invoices | 7 سنوات | احتفاظ قانوني |
| بيانات 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 بكل ما تحتاجه:
خيارات البدء:
- ابدأ بالكود الكامل لملف واحد بعينه — أخبرني أي ملف (مثلًا:
Installer.php,LockManager.php,Salla\Client.php,ImportWizard.tsx). - ابدأ بالـSQL فقط لتشغيل قاعدة البيانات فورًا.
- ابدأ بالـReact Dashboard كاملًا مع الـcomponents.
- ابدأ بسيناريو اختبار E2E من الصفر إلى القفل.
- احصل على OpenAPI 3.0 spec كامل لكل REST endpoints.
- احصل على خطة Sprint-by-Sprint لمدة 8 أسابيع مع توزيع المهام.
سؤال حاسم لك:
ما هو أول ملف تريد أن أُنتج لك كودًا كاملًا قابلًا للنسخ والتشغيل الآن؟
اختر رقمًا من الخيارات أعلاه، أو اكتب اسم الملف، وسأبدأ فورًا في كتابة الكود الإنتاجي بلا pseudo-code.