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 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
JournalEntryService
0.00% covered (danger)
0.00%
0 / 153
0.00% covered (danger)
0.00%
0 / 9
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 createJournalEntry
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
72
 buildJournalLines
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 buildJournalEntryObject
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 validateBalance
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getAccountMappingsKeyed
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 logSyncAttempt
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getSyncStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 syncDailyClose
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\QuickBooks;
4
5use QuickBooksOnline\API\Facades\JournalEntry;
6
7/**
8 * QuickBooks Journal Entry Service
9 *
10 * Creates journal entries in QuickBooks Online from daily close data.
11 * Handles the mapping of S-file fields to QB accounts and constructs
12 * proper debit/credit entries.
13 */
14class JournalEntryService
15{
16    private $store;
17    private $storeDb;
18    private $qbService;
19    private $log;
20
21    /**
22     * @param \Store $store
23     * @param QuickBooksService $qbService
24     */
25    public function __construct(\Store $store, QuickBooksService $qbService = null)
26    {
27        $this->store = $store;
28        $this->storeDb = dbConnectByName($store->getDbName());
29        $this->qbService = $qbService ?: new QuickBooksService($store);
30        $this->log = new \KLogger($_ENV['LOG_DIR'] . "quickbooks.log", \KLogger::DEBUG);
31    }
32
33    /**
34     * Create a journal entry from daily close data
35     *
36     * @param array $dailyData The daily close data (from S-file or close report)
37     * @param string $date The date for the journal entry (YYYY-MM-DD)
38     * @param string $memo Optional memo for the journal entry
39     * @return array Result with success status and journal entry details
40     */
41    public function createJournalEntry(array $dailyData, string $date, string $memo = null)
42    {
43        // Get authenticated DataService
44        $dataService = $this->qbService->getAuthenticatedDataService();
45        if (!$dataService) {
46            return [
47                'success' => false,
48                'error' => 'QuickBooks not authenticated'
49            ];
50        }
51
52        // Get account mappings
53        $mappings = $this->getAccountMappingsKeyed();
54        if (empty($mappings)) {
55            return [
56                'success' => false,
57                'error' => 'No account mappings configured'
58            ];
59        }
60
61        // Build journal entry lines
62        $lines = $this->buildJournalLines($dailyData, $mappings);
63
64        if (empty($lines)) {
65            return [
66                'success' => false,
67                'error' => 'No journal entry lines generated from data'
68            ];
69        }
70
71        // Validate that debits equal credits
72        $validation = $this->validateBalance($lines);
73        if (!$validation['balanced']) {
74            $this->log->logWarn("Journal entry not balanced. Debits: {$validation['totalDebits']}, Credits: {$validation['totalCredits']}");
75            // Add balancing entry to suspense account if configured
76            // For now, we'll proceed and let QB reject if unbalanced
77        }
78
79        // Create the journal entry
80        try {
81            $journalEntry = $this->buildJournalEntryObject($lines, $date, $memo);
82
83            $result = $dataService->Add($journalEntry);
84            $error = $dataService->getLastError();
85
86            if ($error) {
87                $this->log->logError("QuickBooks API error: " . $error->getResponseBody());
88                return [
89                    'success' => false,
90                    'error' => $error->getOAuthHelperError() ?: $error->getResponseBody()
91                ];
92            }
93
94            $this->log->logInfo("Created journal entry {$result->Id} for store {$this->store->getTypeNum()} date {$date}");
95
96            return [
97                'success' => true,
98                'journalEntryId' => $result->Id,
99                'docNumber' => $result->DocNumber ?? null,
100                'totalDebits' => $validation['totalDebits'],
101                'totalCredits' => $validation['totalCredits'],
102                'lineCount' => count($lines)
103            ];
104
105        } catch (\Exception $e) {
106            $this->log->logError("Failed to create journal entry: " . $e->getMessage());
107            return [
108                'success' => false,
109                'error' => $e->getMessage()
110            ];
111        }
112    }
113
114    /**
115     * Build journal entry lines from daily data and mappings
116     *
117     * @param array $dailyData
118     * @param array $mappings
119     * @return array
120     */
121    private function buildJournalLines(array $dailyData, array $mappings)
122    {
123        $lines = [];
124
125        foreach ($dailyData as $fieldName => $value) {
126            // Skip if no value or zero
127            if (empty($value) || floatval($value) == 0) {
128                continue;
129            }
130
131            // Skip if no mapping for this field
132            if (!isset($mappings[$fieldName]) || empty($mappings[$fieldName]['qbAccountId'])) {
133                continue;
134            }
135
136            $mapping = $mappings[$fieldName];
137            $amount = abs(floatval($value));
138
139            // Determine if this should be a debit or credit based on mapping and value sign
140            $isNegative = floatval($value) < 0;
141            $baseEntryType = $mapping['entryType']; // 'debit' or 'credit'
142
143            // If value is negative, reverse the entry type
144            if ($isNegative) {
145                $entryType = ($baseEntryType === 'debit') ? 'credit' : 'debit';
146            } else {
147                $entryType = $baseEntryType;
148            }
149
150            $lines[] = [
151                'fieldName' => $fieldName,
152                'description' => $mapping['fieldDescription'],
153                'accountId' => $mapping['qbAccountId'],
154                'accountName' => $mapping['qbAccountName'],
155                'amount' => $amount,
156                'type' => $entryType // 'debit' or 'credit'
157            ];
158        }
159
160        return $lines;
161    }
162
163    /**
164     * Build the QuickBooks JournalEntry object
165     *
166     * @param array $lines
167     * @param string $date
168     * @param string $memo
169     * @return object
170     */
171    private function buildJournalEntryObject(array $lines, string $date, string $memo = null)
172    {
173        $journalLines = [];
174
175        foreach ($lines as $index => $line) {
176            $postingType = ($line['type'] === 'debit') ? 'Debit' : 'Credit';
177
178            $journalLines[] = [
179                'JournalEntryLineDetail' => [
180                    'PostingType' => $postingType,
181                    'AccountRef' => [
182                        'value' => $line['accountId'],
183                        'name' => $line['accountName']
184                    ]
185                ],
186                'Description' => $line['description'],
187                'Amount' => number_format($line['amount'], 2, '.', ''),
188                'DetailType' => 'JournalEntryLineDetail'
189            ];
190        }
191
192        $journalEntryData = [
193            'TxnDate' => $date,
194            'Line' => $journalLines,
195            'PrivateNote' => $memo ?: "Daily close import for {$this->store->getTypeNum()} - {$date}"
196        ];
197
198        // Add document number based on date and store
199        $docNumber = $this->store->getTypeNum() . '-' . str_replace('-', '', $date);
200        $journalEntryData['DocNumber'] = $docNumber;
201
202        return JournalEntry::create($journalEntryData);
203    }
204
205    /**
206     * Validate that total debits equal total credits
207     *
208     * @param array $lines
209     * @return array
210     */
211    private function validateBalance(array $lines)
212    {
213        $totalDebits = 0;
214        $totalCredits = 0;
215
216        foreach ($lines as $line) {
217            if ($line['type'] === 'debit') {
218                $totalDebits += $line['amount'];
219            } else {
220                $totalCredits += $line['amount'];
221            }
222        }
223
224        // Round to 2 decimal places for comparison
225        $totalDebits = round($totalDebits, 2);
226        $totalCredits = round($totalCredits, 2);
227
228        return [
229            'balanced' => abs($totalDebits - $totalCredits) < 0.01,
230            'totalDebits' => $totalDebits,
231            'totalCredits' => $totalCredits,
232            'difference' => round($totalDebits - $totalCredits, 2)
233        ];
234    }
235
236    /**
237     * Get account mappings keyed by field name
238     *
239     * @return array
240     */
241    private function getAccountMappingsKeyed()
242    {
243        $stmt = $this->storeDb->prepare("
244            SELECT * FROM qb_account_mapping
245            WHERE isActive = 1 AND qbAccountId IS NOT NULL
246        ");
247        $stmt->execute();
248        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
249
250        $keyed = [];
251        foreach ($rows as $row) {
252            $keyed[$row['fieldName']] = $row;
253        }
254        return $keyed;
255    }
256
257    /**
258     * Log a sync attempt to the sync log table
259     *
260     * @param string $date
261     * @param string $syncType
262     * @param array $result
263     * @param array $requestPayload
264     * @param int $userId
265     * @return int|null Insert ID
266     */
267    public function logSyncAttempt(string $date, string $syncType, array $result, array $requestPayload = null, int $userId = null)
268    {
269        try {
270            $stmt = $this->storeDb->prepare("
271                INSERT INTO qb_sync_log
272                (syncDate, syncType, status, journalEntryId, journalEntryDocNum, requestPayload, responsePayload, errorMessage, syncedBy, startedAt, completedAt)
273                VALUES
274                (:syncDate, :syncType, :status, :jeId, :docNum, :reqPayload, :respPayload, :errorMsg, :userId, NOW(), NOW())
275                ON DUPLICATE KEY UPDATE
276                status = VALUES(status),
277                journalEntryId = VALUES(journalEntryId),
278                journalEntryDocNum = VALUES(journalEntryDocNum),
279                responsePayload = VALUES(responsePayload),
280                errorMessage = VALUES(errorMessage),
281                retryCount = retryCount + 1,
282                completedAt = NOW()
283            ");
284
285            $status = $result['success'] ? 'success' : 'failed';
286
287            $stmt->execute([
288                ':syncDate' => $date,
289                ':syncType' => $syncType,
290                ':status' => $status,
291                ':jeId' => $result['journalEntryId'] ?? null,
292                ':docNum' => $result['docNumber'] ?? null,
293                ':reqPayload' => $requestPayload ? json_encode($requestPayload) : null,
294                ':respPayload' => json_encode($result),
295                ':errorMsg' => $result['error'] ?? null,
296                ':userId' => $userId
297            ]);
298
299            return $this->storeDb->lastInsertId();
300        } catch (\Exception $e) {
301            $this->log->logError("Failed to log sync attempt: " . $e->getMessage());
302            return null;
303        }
304    }
305
306    /**
307     * Check if a sync has already been completed for a given date
308     *
309     * @param string $date
310     * @return array|null
311     */
312    public function getSyncStatus(string $date)
313    {
314        $stmt = $this->storeDb->prepare("
315            SELECT * FROM qb_sync_log
316            WHERE syncDate = :date
317            ORDER BY createdAt DESC
318            LIMIT 1
319        ");
320        $stmt->execute([':date' => $date]);
321        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
322    }
323
324    /**
325     * Process daily close data and sync to QuickBooks
326     *
327     * @param string $date Date in YYYY-MM-DD format
328     * @param array $dailyData Daily close data
329     * @param string $syncType 'daily_close', 'manual', or 'retry'
330     * @param int $userId User ID initiating the sync
331     * @return array
332     */
333    public function syncDailyClose(string $date, array $dailyData, string $syncType = 'daily_close', int $userId = null)
334    {
335        // Check if already synced successfully
336        $existingSync = $this->getSyncStatus($date);
337        if ($existingSync && $existingSync['status'] === 'success') {
338            return [
339                'success' => false,
340                'error' => 'Already synced successfully',
341                'existingSync' => $existingSync
342            ];
343        }
344
345        // Mark as processing
346        $this->logSyncAttempt($date, $syncType, ['success' => false, 'error' => 'Processing...'], $dailyData, $userId);
347
348        // Create the journal entry
349        $memo = "Daily Sales - {$this->store->getCompanyName()} ({$this->store->getTypeNum()}) - {$date}";
350        $result = $this->createJournalEntry($dailyData, $date, $memo);
351
352        // Log the result
353        $this->logSyncAttempt($date, $syncType, $result, $dailyData, $userId);
354
355        // Update store's last sync timestamp on success
356        if ($result['success']) {
357            $this->store->setQbLastSync(date('Y-m-d H:i:s'));
358            $this->store->updateQbStatus();
359        }
360
361        return $result;
362    }
363}