Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 153 |
|
0.00% |
0 / 16 |
CRAP | |
0.00% |
0 / 1 |
| QuickBooksService | |
0.00% |
0 / 153 |
|
0.00% |
0 / 16 |
2070 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| getDataService | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| getAuthorizationUrl | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
| validateOAuthState | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
156 | |||
| getAppDomain | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| exchangeCodeForTokens | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
| saveTokens | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| getAuthenticatedDataService | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
| refreshAccessToken | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| getCompanyInfo | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| getAccounts | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| disconnect | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
| getAccountMappings | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| updateAccountMapping | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
| isConfigured | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| getStore | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace BuyerKiosk\QuickBooks; |
| 4 | |
| 5 | use QuickBooksOnline\API\DataService\DataService; |
| 6 | use 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 | */ |
| 14 | class 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 | } |