| 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 |
22 | declare(strict_types=1);
23 |
24 | namespace SwapAds\Client\License;
25 |
26 | use SwapAds\Client\Api\RestClient;
27 |
28 | final 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 | }