Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FreemiusWebhookForwarder
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 7
380
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onLicenseLoaded
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onLicenseChange
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 onAccountChange
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 onAfterUninstall
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 forward
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
56
 getLastResult
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Freemius Webhook Forwarder (CF17.1 Option B - client side).
4 *
5 * Listens to Freemius SDK action events for license lifecycle changes
6 * (cancellation, refund, expiration, etc.) and forwards them to the
7 * server's /v1/webhooks/status endpoint.
8 *
9 * This is the FAST path for license status sync:
10 *   - Server cron (every 48h) = ground truth, catches silent changes
11 *   - Client webhook (real-time) = fast path, instant on cancellation
12 *
13 * Both write to the same licenses.status column; whichever runs last wins.
14 *
15 * Security:
16 *   - Webhook calls authenticated with X-SwapAds-Webhook-Secret header
17 *   - Same secret the server uses; rotated via swapads_client_webhook_secret option
18 *
19 * @since 1.3.0
20 */
21
22declare(strict_types=1);
23
24namespace SwapAds\Client\License;
25
26use SwapAds\Client\Api\RestClient;
27
28final class FreemiusWebhookForwarder
29{
30    public const OPTION_WEBHOOK_SECRET = 'swapads_client_webhook_secret';
31    public const OPTION_LAST_FORWARD_AT = 'swapads_client_last_webhook_forward_at';
32    public const OPTION_LAST_FORWARD_RESULT = 'swapads_client_last_webhook_forward_result';
33
34    /**
35     * Register Freemius action listeners.
36     *
37     * @return void
38     *
39     * @since 1.3.0
40     */
41    public static function register(): void
42    {
43        // Map Freemius SDK events to our server webhook event types.
44        // SDK fires these actions when license state changes upstream.
45        add_action('swa_fs_license_loaded', [self::class, 'onLicenseLoaded']);
46        add_action('swa_fs_license_change', [self::class, 'onLicenseChange']);
47        // Plugin-level events
48        add_action('swa_fs_after_uninstall', [self::class, 'onAfterUninstall']);
49        // Catch-all for any state change
50        add_action('swa_fs_account_change', [self::class, 'onAccountChange']);
51    }
52
53    /**
54     * Handle license_loaded event (SDK fires this once on first load after opt-in).
55     *
56     * @param object $license SDK license object.
57     * @return void
58     */
59    public static function onLicenseLoaded($license): void
60    {
61        if (!is_object($license)) {
62            return;
63        }
64        self::forward('license.loaded', [
65            'license_id' => (string) ($license->id ?? ''),
66            'plan_id'    => (string) ($license->plan_id ?? ''),
67            'secret_key' => (string) ($license->secret_key ?? ''),
68        ]);
69    }
70
71    /**
72     * Handle license_change event (fires when license object updates).
73     *
74     * @param object $license SDK license object.
75     * @return void
76     */
77    public static function onLicenseChange($license): void
78    {
79        if (!is_object($license)) {
80            return;
81        }
82        self::forward('license.updated', [
83            'license_id' => (string) ($license->id ?? ''),
84            'plan_id'    => (string) ($license->plan_id ?? ''),
85            'secret_key' => (string) ($license->secret_key ?? ''),
86        ]);
87    }
88
89    /**
90     * Handle account_change event (fires when FS account details change).
91     *
92     * @param object $account SDK account object.
93     * @return void
94     */
95    public static function onAccountChange($account): void
96    {
97        if (!is_object($account)) {
98            return;
99        }
100        // Map account changes to license status updates.
101        $event = 'license.updated';
102        if (isset($account->is_active) && !$account->is_active) {
103            $event = 'license.cancelled';
104        }
105        self::forward($event, [
106            'account_id' => (string) ($account->id ?? ''),
107            'is_active'  => (bool) ($account->is_active ?? false),
108        ]);
109    }
110
111    /**
112     * Handle after_uninstall event (fires when user uninstalls plugin).
113     *
114     * @return void
115     */
116    public static function onAfterUninstall(): void
117    {
118        self::forward('license.cancelled', [
119            'reason' => 'plugin_uninstalled',
120        ]);
121    }
122
123    /**
124     * Forward a Freemius event to the server's /v1/webhooks/status endpoint.
125     *
126     * @param string $eventType Event type (license.loaded, license.cancelled, etc.)
127     * @param array<string, mixed> $payload Event payload.
128     * @return bool True if forward succeeded.
129     *
130     * @since 1.3.0
131     */
132    public static function forward(string $eventType, array $payload): bool
133    {
134        if (!LicenseManager::isLicensed()) {
135            return false;
136        }
137
138        $webhookSecret = (string) get_option(self::OPTION_WEBHOOK_SECRET, '');
139        if ($webhookSecret === '') {
140            // Operator hasn't configured webhook secret yet - silently skip.
141            // The 48h cron will still catch license changes.
142            return false;
143        }
144
145        $serverUrl = (string) get_option('swapads_client_server_url', '');
146        if ($serverUrl === '') {
147            return false;
148        }
149
150        // Build request with custom X-SwapAds-Webhook-Secret header.
151        $url = rtrim($serverUrl, '/') . '/wp-json/swapads-server/v1/webhooks/status';
152        $body = [
153            'type' => $eventType,
154        ] + $payload;
155
156        $response = wp_remote_post($url, [
157            'headers' => [
158                'Content-Type' => 'application/json',
159                'X-SwapAds-Webhook-Secret' => $webhookSecret,
160            ],
161            'body' => (string) json_encode($body),
162            'timeout' => 10,
163        ]);
164
165        update_option(self::OPTION_LAST_FORWARD_AT, time());
166
167        if (!is_array($response)) {
168            update_option(self::OPTION_LAST_FORWARD_RESULT, [
169                'success' => false,
170                'error' => 'wp_remote_post returned non-array response',
171                'event' => $eventType,
172            ]);
173            return false;
174        }
175
176        $code = (int) wp_remote_retrieve_response_code($response);
177        $raw = (string) wp_remote_retrieve_body($response);
178        $data = json_decode($raw, true);
179        $success = $code >= 200 && $code < 300;
180
181        update_option(self::OPTION_LAST_FORWARD_RESULT, [
182            'success' => $success,
183            'http_code' => $code,
184            'event' => $eventType,
185            'response' => is_array($data) ? $data : [],
186        ]);
187
188        return $success;
189    }
190
191    /**
192     * Get the last forward result (for diagnostics in admin UI).
193     *
194     * @return array<string, mixed>
195     */
196    public static function getLastResult(): array
197    {
198        $result = get_option(self::OPTION_LAST_FORWARD_RESULT, []);
199        return is_array($result) ? $result : [];
200    }
201}