Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 153
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuickBooksService
0.00% covered (danger)
0.00%
0 / 153
0.00% covered (danger)
0.00%
0 / 16
2070
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getDataService
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthorizationUrl
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 validateOAuthState
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
156
 getAppDomain
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 exchangeCodeForTokens
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 saveTokens
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthenticatedDataService
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 refreshAccessToken
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getCompanyInfo
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getAccounts
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 disconnect
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getAccountMappings
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 updateAccountMapping
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 isConfigured
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getStore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\QuickBooks;
4
5use QuickBooksOnline\API\DataService\DataService;
6use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2AccessToken;
7
8/**
9 * QuickBooks Online Integration Service
10 *
11 * Handles OAuth authentication, token management, and API interactions
12 * with QuickBooks Online.
13 */
14class QuickBooksService
15{
16    private $store;
17    private $storeDb;
18    private $log;
19
20    // OAuth configuration (loaded from environment)
21    private $clientId;
22    private $clientSecret;
23    private $redirectUri;
24    private $environment; // 'development' or 'production'
25
26    // QuickBooks URLs
27    const AUTH_URL = "https://appcenter.intuit.com/connect/oauth2";
28    const TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer";
29    const SCOPE = "com.intuit.quickbooks.accounting";
30
31    /**
32     * @param \Store $store The store to configure QuickBooks for
33     */
34    public function __construct(\Store $store)
35    {
36        $this->store = $store;
37        $this->storeDb = dbConnectByName($store->getDbName());
38        $this->log = new \KLogger($_ENV['LOG_DIR'] . "quickbooks.log", \KLogger::DEBUG);
39
40        // Load OAuth credentials from environment variables
41        $this->clientId = $_ENV['QB_CLIENT_ID'] ?? null;
42        $this->clientSecret = $_ENV['QB_CLIENT_SECRET'] ?? null;
43        $this->redirectUri = $_ENV['QB_REDIRECT_URI'] ?? 'https://dev2.buyerkiosk.com/api/qbCallback.php';
44        $this->environment = $_ENV['QB_ENVIRONMENT'] ?? 'development';
45
46        if (empty($this->clientId) || empty($this->clientSecret)) {
47            $this->log->logError("QuickBooks OAuth credentials not configured in environment");
48        }
49    }
50
51    /**
52     * Get the DataService configured for OAuth
53     * @return DataService
54     */
55    public function getDataService()
56    {
57        return DataService::Configure([
58            'auth_mode' => 'oauth2',
59            'ClientID' => $this->clientId,
60            'ClientSecret' => $this->clientSecret,
61            'RedirectURI' => $this->redirectUri,
62            'scope' => self::SCOPE,
63            'baseUrl' => $this->environment
64        ]);
65    }
66
67    /**
68     * Get the OAuth2 authorization URL for connecting to QuickBooks
69     * Generates a secure CSRF token and stores it in the session
70     *
71     * @return string The authorization URL to redirect the user to
72     */
73    public function getAuthorizationUrl()
74    {
75        // Generate a secure CSRF token
76        $csrfToken = bin2hex(random_bytes(32));
77
78        // Store the CSRF token and typeNum in session for validation on callback
79        if (session_status() === PHP_SESSION_NONE) {
80            session_start();
81        }
82        $_SESSION['qb_oauth_csrf'] = $csrfToken;
83        $_SESSION['qb_oauth_typenum'] = $this->store->getTypeNum();
84        $_SESSION['qb_oauth_timestamp'] = time();
85
86        // Build state as JSON containing both typeNum and CSRF token
87        $state = base64_encode(json_encode([
88            'typeNum' => $this->store->getTypeNum(),
89            'csrf' => $csrfToken
90        ]));
91
92        // Build the authorization URL
93        $params = [
94            'client_id' => $this->clientId,
95            'response_type' => 'code',
96            'scope' => self::SCOPE,
97            'redirect_uri' => $this->redirectUri,
98            'state' => $state
99        ];
100
101        return self::AUTH_URL . '?' . http_build_query($params);
102    }
103
104    /**
105     * Validate the OAuth state parameter from callback
106     *
107     * @param string $state The state parameter from the callback
108     * @return array|false Returns ['typeNum' => string] on success, false on failure
109     */
110    public static function validateOAuthState($state)
111    {
112        if (empty($state)) {
113            return false;
114        }
115
116        // Decode the state parameter
117        $decoded = json_decode(base64_decode($state), true);
118        if (!$decoded || !isset($decoded['typeNum']) || !isset($decoded['csrf'])) {
119            return false;
120        }
121
122        // Start session if needed
123        if (session_status() === PHP_SESSION_NONE) {
124            session_start();
125        }
126
127        // Verify CSRF token matches
128        if (!isset($_SESSION['qb_oauth_csrf']) ||
129            !hash_equals($_SESSION['qb_oauth_csrf'], $decoded['csrf'])) {
130            return false;
131        }
132
133        // Verify typeNum matches
134        if (!isset($_SESSION['qb_oauth_typenum']) ||
135            $_SESSION['qb_oauth_typenum'] !== $decoded['typeNum']) {
136            return false;
137        }
138
139        // Verify token hasn't expired (15 minute window)
140        if (!isset($_SESSION['qb_oauth_timestamp']) ||
141            (time() - $_SESSION['qb_oauth_timestamp']) > 900) {
142            return false;
143        }
144
145        // Clear the CSRF tokens from session (one-time use)
146        unset($_SESSION['qb_oauth_csrf']);
147        unset($_SESSION['qb_oauth_typenum']);
148        unset($_SESSION['qb_oauth_timestamp']);
149
150        return ['typeNum' => $decoded['typeNum']];
151    }
152
153    /**
154     * Get the application domain based on environment
155     *
156     * @return string The domain (e.g., 'https://dev2.buyerkiosk.com' or 'https://buyerkiosk.com')
157     */
158    public static function getAppDomain()
159    {
160        $environment = $_ENV['QB_ENVIRONMENT'] ?? 'development';
161
162        if ($environment === 'production') {
163            return 'https://buyerkiosk.com';
164        }
165
166        return 'https://dev2.buyerkiosk.com';
167    }
168
169    /**
170     * Exchange authorization code for access tokens
171     *
172     * @param string $code The authorization code from QuickBooks
173     * @param string $realmId The QuickBooks company/realm ID
174     * @return OAuth2AccessToken|null
175     */
176    public function exchangeCodeForTokens($code, $realmId)
177    {
178        try {
179            $dataService = $this->getDataService();
180            $oauth2LoginHelper = $dataService->getOAuth2LoginHelper();
181
182            $accessToken = $oauth2LoginHelper->exchangeAuthorizationCodeForToken($code, $realmId);
183
184            if ($accessToken) {
185                // Save tokens to store
186                $this->saveTokens($accessToken, $realmId);
187                $this->log->logInfo("Successfully exchanged auth code for tokens for store: " . $this->store->getTypeNum());
188                return $accessToken;
189            }
190        } catch (\Exception $e) {
191            $this->log->logError("Failed to exchange auth code: " . $e->getMessage());
192        }
193
194        return null;
195    }
196
197    /**
198     * Save OAuth tokens to the store record
199     *
200     * @param OAuth2AccessToken $accessToken
201     * @param string $realmId
202     */
203    private function saveTokens(OAuth2AccessToken $accessToken, $realmId)
204    {
205        $this->store->setQbAccessToken($accessToken->getAccessToken());
206        $this->store->setQbRefreshToken($accessToken->getRefreshToken());
207
208        // Format expiration times
209        $accessExpires = new \DateTime($accessToken->getAccessTokenExpiresAt(), new \DateTimeZone('UTC'));
210        $refreshExpires = new \DateTime($accessToken->getRefreshTokenExpiresAt(), new \DateTimeZone('UTC'));
211
212        $this->store->setQbAccessTokenExpiration($accessExpires->format('Y-m-d H:i:s'));
213        $this->store->setQbRefreshTokenExpiration($refreshExpires->format('Y-m-d H:i:s'));
214        $this->store->setQbRealmID($realmId);
215        $this->store->setQbEnabled(1);
216
217        // Persist to database
218        $this->store->updateQbAccessToken();
219        $this->store->updateQbRefreshToken();
220        $this->store->updateQbStatus();
221    }
222
223    /**
224     * Get an authenticated DataService with valid tokens
225     * Automatically refreshes expired access tokens
226     *
227     * @return DataService|null Returns configured DataService or null if not authenticated
228     */
229    public function getAuthenticatedDataService()
230    {
231        if (!$this->store->isQbConfigured()) {
232            $this->log->logWarn("QuickBooks not configured for store: " . $this->store->getTypeNum());
233            return null;
234        }
235
236        $dataService = $this->getDataService();
237
238        // Check if access token is expired
239        $accessExpiration = new \DateTime($this->store->getQbAccessTokenExpiration(), new \DateTimeZone('UTC'));
240        $now = new \DateTime('now', new \DateTimeZone('UTC'));
241
242        if ($accessExpiration <= $now) {
243            // Token expired, try to refresh
244            $this->log->logInfo("Access token expired for store: " . $this->store->getTypeNum() . ", refreshing...");
245
246            if (!$this->refreshAccessToken($dataService)) {
247                return null;
248            }
249        }
250
251        // Create OAuth2AccessToken with current credentials
252        $accessToken = new OAuth2AccessToken($this->clientId, $this->clientSecret);
253        $accessToken->setAccessToken($this->store->getQbAccessToken());
254        $accessToken->setRefreshToken($this->store->getQbRefreshToken());
255        $accessToken->setAccessTokenExpiresAt(new \DateTime($this->store->getQbAccessTokenExpiration()));
256        $accessToken->setRefreshTokenExpiresAt(new \DateTime($this->store->getQbRefreshTokenExpiration()));
257        $accessToken->setRealmID($this->store->getQbRealmID());
258
259        $dataService->updateOAuth2Token($accessToken);
260
261        return $dataService;
262    }
263
264    /**
265     * Refresh the access token using the refresh token
266     *
267     * @param DataService $dataService
268     * @return bool Whether refresh was successful
269     */
270    private function refreshAccessToken(DataService $dataService)
271    {
272        try {
273            $oauth2LoginHelper = $dataService->getOAuth2LoginHelper();
274            $newToken = $oauth2LoginHelper->refreshAccessTokenWithRefreshToken($this->store->getQbRefreshToken());
275
276            if ($newToken) {
277                $newToken->setRealmID($this->store->getQbRealmID());
278                $this->saveTokens($newToken, $this->store->getQbRealmID());
279                $this->log->logInfo("Successfully refreshed access token for store: " . $this->store->getTypeNum());
280                return true;
281            }
282        } catch (\Exception $e) {
283            $this->log->logError("Failed to refresh token for store " . $this->store->getTypeNum() . ": " . $e->getMessage());
284
285            // If refresh token is invalid, disable QB integration
286            $this->store->setQbEnabled(0);
287            $this->store->updateQbStatus();
288        }
289
290        return false;
291    }
292
293    /**
294     * Get the QuickBooks company info
295     *
296     * @return object|null Company info or null on failure
297     */
298    public function getCompanyInfo()
299    {
300        $dataService = $this->getAuthenticatedDataService();
301        if (!$dataService) {
302            return null;
303        }
304
305        try {
306            return $dataService->getCompanyInfo();
307        } catch (\Exception $e) {
308            $this->log->logError("Failed to get company info: " . $e->getMessage());
309            return null;
310        }
311    }
312
313    /**
314     * Get all accounts from QuickBooks Chart of Accounts
315     *
316     * @param int $startPosition Starting position for pagination
317     * @param int $maxResults Maximum results to return (max 1000)
318     * @return array Array of Account objects
319     */
320    public function getAccounts($startPosition = 1, $maxResults = 1000)
321    {
322        $dataService = $this->getAuthenticatedDataService();
323        if (!$dataService) {
324            return [];
325        }
326
327        try {
328            $accounts = $dataService->FindAll('Account', $startPosition, $maxResults);
329            return $accounts ?: [];
330        } catch (\Exception $e) {
331            $this->log->logError("Failed to get accounts: " . $e->getMessage());
332            return [];
333        }
334    }
335
336    /**
337     * Disconnect QuickBooks integration
338     * Clears all tokens and disables integration
339     */
340    public function disconnect()
341    {
342        $this->store->setQbAccessToken(null);
343        $this->store->setQbRefreshToken(null);
344        $this->store->setQbAccessTokenExpiration(null);
345        $this->store->setQbRefreshTokenExpiration(null);
346        $this->store->setQbRealmID(null);
347        $this->store->setQbEnabled(0);
348        $this->store->setQbLastSync(null);
349
350        $this->store->updateQbAccessToken();
351        $this->store->updateQbRefreshToken();
352        $this->store->updateQbStatus();
353
354        $this->log->logInfo("QuickBooks disconnected for store: " . $this->store->getTypeNum());
355    }
356
357    /**
358     * Get the account mapping for this store
359     *
360     * @return array Array of field mappings with QB account info
361     */
362    public function getAccountMappings()
363    {
364        $stmt = $this->storeDb->prepare("
365            SELECT * FROM qb_account_mapping
366            WHERE isActive = 1
367            ORDER BY fieldCategory, fieldName
368        ");
369        $stmt->execute();
370        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
371    }
372
373    /**
374     * Update an account mapping
375     *
376     * @param string $fieldName The S-file field name
377     * @param string $qbAccountId QuickBooks account ID
378     * @param string $qbAccountName QuickBooks account name
379     * @param string $qbAccountType QuickBooks account type
380     * @return bool Success
381     */
382    public function updateAccountMapping($fieldName, $qbAccountId, $qbAccountName, $qbAccountType = null)
383    {
384        try {
385            $stmt = $this->storeDb->prepare("
386                UPDATE qb_account_mapping
387                SET qbAccountId = :qbAccountId,
388                    qbAccountName = :qbAccountName,
389                    qbAccountType = :qbAccountType,
390                    updatedAt = NOW()
391                WHERE fieldName = :fieldName
392            ");
393
394            return $stmt->execute([
395                ':qbAccountId' => $qbAccountId,
396                ':qbAccountName' => $qbAccountName,
397                ':qbAccountType' => $qbAccountType,
398                ':fieldName' => $fieldName
399            ]);
400        } catch (\Exception $e) {
401            $this->log->logError("Failed to update account mapping: " . $e->getMessage());
402            return false;
403        }
404    }
405
406    /**
407     * Check if OAuth credentials are configured
408     * @return bool
409     */
410    public function isConfigured()
411    {
412        return !empty($this->clientId) && !empty($this->clientSecret);
413    }
414
415    /**
416     * Get the store this service is configured for
417     * @return \Store
418     */
419    public function getStore()
420    {
421        return $this->store;
422    }
423}