HarvestService::getCompany()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 6
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 13
rs 10
1
<?php
2
/**
3
 * SaaS Link plugin for Craft CMS 3.x
4
 *
5
 * @link      https://workingconcept.com
6
 * @copyright Copyright (c) 2018 Working Concept Inc.
7
 */
8
9
namespace workingconcept\saaslink\services;
10
11
use workingconcept\saaslink\SaasLink;
12
use workingconcept\saaslink\models\harvest\HarvestClient;
13
use workingconcept\saaslink\models\harvest\HarvestExpense;
14
use workingconcept\saaslink\models\harvest\HarvestInvoice;
15
use workingconcept\saaslink\models\harvest\HarvestProject;
16
use workingconcept\saaslink\models\harvest\HarvestTimeEntry;
17
use workingconcept\saaslink\models\harvest\HarvestUser;
18
use workingconcept\saaslink\models\harvest\HarvestCompany;
19
use Craft;
20
21
class HarvestService extends SaasLinkService
22
{
23
    // Constants
24
    // =========================================================================
25
26
    const CACHE_ENABLED = true;
27
    const CACHE_SECONDS = 60;
28
    const TIME_ROUNDING_METHOD = 'nextHalfHour'; // nextHalfHour, nearestHalfHour, nextWholeNumber, nearestWholeNumber
29
30
31
    // Properties
32
    // =========================================================================
33
34
    /**
35
     * @var string
36
     */
37
    protected $apiBaseUrl  = 'https://api.harvestapp.com/v2/';
38
39
    /**
40
     * @var string
41
     */
42
    public $serviceName = 'Harvest';
43
44
    /**
45
     * @var string
46
     */
47
    public $serviceSlug = 'harvest';
48
49
    /**
50
     * @var HarvestProject
51
     */
52
    private $project;
53
54
55
    // Public Methods
56
    // =========================================================================
57
58
    /**
59
     * @inheritdoc
60
     */
61
    public function isConfigured(): bool
62
    {
63
        return ! empty($this->settings->harvestToken) && ! empty($this->settings->harvestAccountId);
64
    }
65
66
    /**
67
     * @inheritdoc
68
     */
69
    public function configureClient()
70
    {
71
        $this->client = new \GuzzleHttp\Client([
72
            'base_uri' => $this->apiBaseUrl,
73
            'headers' => [
74
                'Authorization'      => 'Bearer ' . $this->settings->harvestToken,
75
                'Harvest-Account-Id' => $this->settings->harvestAccountId,
76
                'User-Agent'         => 'Craft CMS',
77
            ],
78
            'verify' => false,
79
            'debug' => false
80
        ]);
81
82
        if (empty($this->settings->harvestBaseUrl))
83
        {
84
            $this->setHarvestBaseUrlSetting();
85
        }
86
    }
87
88
    /**
89
     * Save a reference to the customer-facing base Harvest URL so we don't have to keep looking it up.
90
     */
91
    public function setHarvestBaseUrlSetting()
92
    {
93
        $this->settings->harvestBaseUrl = $this->getCompany()->base_uri;
94
95
        // let the base plugin class worry about *saving* the settings model
96
        // Craft::$app->plugins->savePluginSettings(SaasLink::$plugin, $this->settings->toArray());
97
    }
98
99
    /**
100
     * @inheritdoc
101
     */
102
    public function getAvailableRelationshipTypes(): array
103
    {
104
        return [
105
            [
106
                'label' => Craft::t('saas-link', 'Client'),
107
                'value' => 'client'
108
            ],
109
            [
110
                'label' => Craft::t('saas-link', 'Project'),
111
                'value' => 'project'
112
            ],
113
        ];
114
    }
115
116
    /**
117
     * @inheritdoc
118
     */
119
    public function getOptions($relationshipType): array
120
    {
121
        $options = [];
122
123
        if ($relationshipType === 'client')
124
        {
125
            foreach ($this->getClients() as $client)
126
            {
127
                $options[] = [
128
                    'label'   => $client->name,
129
                    'value'   => (string)$client->id,
130
                    'link'    => $this->settings->harvestBaseUrl . '/reports/clients/' . $client->id,
131
                    'default' => null
132
                ];
133
            }
134
135
            // alphabetize
136
            usort($options, function($a, $b) {
137
                return strtolower($a['label']) <=> strtolower($b['label']);
138
            });
139
        }
140
        elseif ($relationshipType === 'project')
141
        {
142
            $projects = $this->getProjects();
143
144
            foreach ($projects as $project)
145
            {
146
                $options[] = [
147
                    'label'   => $project->name . ' (' . $project->client->name . ')',
148
                    'value'   => (string)$project->id,
149
                    'link'    => $this->settings->harvestBaseUrl . '/projects/' . $project->id,
150
                    'default' => null
151
                ];
152
            }
153
        }
154
155
        return $options;
156
    }
157
158
    /**
159
     * Get company information
160
     * https://help.getharvest.com/api-v2/company-api/company/company/
161
     *
162
     * @return HarvestCompany
163
     */
164
    public function getCompany(): HarvestCompany
165
    {
166
        if ($cachedResponse = Craft::$app->cache->get('harvest_company'))
167
        {
168
            return new HarvestCompany($cachedResponse);
169
        }
170
171
        $response = $this->client->get('company');
172
        $responseData = json_decode($response->getBody(), true);
173
174
        Craft::$app->cache->set('harvest_company', $responseData, 3600);
175
176
        return new HarvestCompany($responseData);
177
    }
178
179
    /**
180
     * Get client list.
181
     * https://help.getharvest.com/api-v2/clients-api/clients/clients/
182
     *
183
     * @return array HarvestClient models
184
     */
185
    public function getClients(): array
186
    {
187
        $clients = [];
188
        $result  = $this->collectPaginatedResults('clients');
189
190
        foreach ($result as $clientData)
191
        {
192
            $clients[] = new HarvestClient($clientData);
193
        }
194
195
        return $clients;
196
    }
197
198
    /**
199
     * Get project list.
200
     * https://help.getharvest.com/api-v2/projects-api/projects/projects/
201
     *
202
     * @return array HarvestProject models
203
     */
204
    public function getProjects(): array
205
    {
206
        $projects = [];
207
        $result   = $this->collectPaginatedResults('projects');
208
209
        foreach ($result as $projectData)
210
        {
211
            $projects[] = new HarvestProject($projectData);
212
        }
213
214
        return $projects;
215
    }
216
217
    /**
218
     * Get user list.
219
     * https://help.getharvest.com/api-v2/users-api/users/users/
220
     *
221
     * @return array HarvestUser models
222
     */
223
    public function getUsers(): array
224
    {
225
        $users  = [];
226
        $result = $this->collectPaginatedResults('users');
227
228
        foreach ($result as $userData)
229
        {
230
            $users[] = new HarvestUser($userData);
231
        }
232
233
        return $users;
234
    }
235
236
    /**
237
     * Get project details.
238
     * https://help.getharvest.com/api-v2/projects-api/projects/projects/#retrieve-a-project
239
     *
240
     * @param  int $projectId  ID of relevant Harvest project
241
     *
242
     * @return HarvestProject
243
     */
244
    public function getProject($projectId): HarvestProject
245
    {
246
        if ( ! $this->project || $this->project->id !== $projectId)
247
        {
248
            $response     = $this->client->get('projects/' . $projectId);
249
            $responseData = json_decode($response->getBody(), true);
250
            $project      = new HarvestProject($responseData);
251
252
            $this->project = $project;
253
        }
254
255
        return $this->project;
256
    }
257
258
    /**
259
     * Get projects for a specific client.
260
     * https://help.getharvest.com/api-v2/projects-api/projects/projects/#list-all-projects
261
     *
262
     * @param  int     $clientId  ID of relevant Harvest client
263
     * @param  boolean $active    whether to retrieve projects that are active
264
     *
265
     * @return array HarvestProject models
266
     */
267
    public function getClientProjects($clientId, $active): array
268
    {
269
        $projects = [];
270
        $result   = $this->collectPaginatedResults('projects?client_id='
271
            . $clientId
272
            . '&is_active=' . ($active ? 'true' : 'false')
273
        );
274
275
        foreach ($result as $projectData)
276
        {
277
            $projects[] = new HarvestProject($projectData);
278
        }
279
280
        return $projects;
281
    }
282
283
    /**
284
     * Get all time entries logged to a project.
285
     *
286
     * https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/
287
     *
288
     * @param  int   $projectId  ID of relevant Harvest project
289
     *
290
     * @return array HarvestTimeEntry models
291
     */
292
    public function getProjectTimeEntries($projectId): array
293
    {
294
        $entries = [];
295
        $result  = $this->collectPaginatedResults('time_entries?project_id=' . $projectId);
296
297
        foreach ($result as $entryData)
298
        {
299
            $entries[] = new HarvestTimeEntry($entryData);
300
        }
301
302
        return $entries;
303
    }
304
305
    /**
306
     * Get all time entries logged by the given user.
307
     *
308
     * https://help.getharvest.com/api-v2/timesheets-api/timesheets/time-entries/
309
     *
310
     * @param  int   $userId  ID of relevant Harvest user
311
     *
312
     * @return array HarvestTimeEntry models
313
     */
314
    public function getUserTimeEntries($userId): array
315
    {
316
        $entries = [];
317
        $result  = $this->collectPaginatedResults('time_entries?user_id=' . $userId);
318
319
        foreach ($result as $entryData)
320
        {
321
            $entries[] = new HarvestTimeEntry($entryData);
322
        }
323
324
        return $entries;
325
    }
326
327
    /**
328
     * Get all expenses related to a project.
329
     *
330
     * https://help.getharvest.com/api-v2/expenses-api/expenses/expenses/
331
     *
332
     * @param  int   $projectId  ID of relevant Harvest project
333
     *
334
     * @return array HarvestExpense models
335
     */
336
    public function getProjectExpenses($projectId): array
337
    {
338
        $expenses = [];
339
        $result   = $this->collectPaginatedResults('expenses?project_id=' . $projectId);
340
341
        foreach ($result as $expenseData)
342
        {
343
            $expenses[] = new HarvestExpense($expenseData);
344
        }
345
346
        return $expenses;
347
    }
348
349
    /**
350
     * Get all invoices related to a project.
351
     * https://help.getharvest.com/api-v2/invoices-api/invoices/invoices/
352
     *
353
     * @param  int   $projectId  ID of relevant Harvest project
354
     *
355
     * @return array             API response with Invoice objects
356
     */
357
    public function getProjectInvoices($projectId): array
358
    {
359
        $invoices = [];
360
        $result   = $this->collectPaginatedResults('invoices?project_id=' . $projectId);
361
362
        foreach ($result as $invoiceData)
363
        {
364
            $invoices[] = new HarvestInvoice($invoiceData);
365
        }
366
367
        return $invoices;
368
    }
369
370
    /**
371
     * Get the total number of hours logged on a given project.
372
     *
373
     * @param int     $projectId            ID of relevant Harvest project
374
     * @param boolean $billableOnly         only count billable hours (default true)
375
     * @param boolean $individuallyRounded  round time to nearest half hour before adding to the total (default true)
376
     *
377
     * @return float
378
     */
379
    public function getTotalProjectHours($projectId, $billableOnly = false, $individuallyRounded = true): float
380
    {
381
        $timeEntries       = $this->getProjectTimeEntries($projectId);
382
        $totalRoundedHours = 0;
383
        $totalHours        = 0;
384
        $hoursByPerson     = [];
385
386
        foreach ($timeEntries as $timeEntry)
387
        {
388
            if ($timeEntry->billable || $billableOnly === false)
389
            {
390
                $roundedHours       = $this->roundTime($timeEntry->hours, self::TIME_ROUNDING_METHOD);
391
                $totalRoundedHours += $roundedHours;
392
                $totalHours        += $timeEntry->hours;
393
394
                if ( ! isset($hoursByPerson[$timeEntry->user->name]))
395
                {
396
                    $hoursByPerson[$timeEntry->user->name] = $timeEntry->hours;
397
                }
398
                else
399
                {
400
                    $hoursByPerson[$timeEntry->user->name] += $timeEntry->hours;
401
                }
402
403
                //echo "$timeEntry->hours // {$timeEntry->user->name} on $timeEntry->spent_date\n";
404
            }
405
        }
406
407
        //print_r($hoursByPerson);
408
409
        // echo "-------------------------------------------";
410
        // echo "total: " . $totalHours;
411
        // echo "total: " . $totalRoundedHours . " (rounded)";
412
413
        return $individuallyRounded ? $totalRoundedHours : $totalHours;
414
    }
415
416
    /**
417
     * Get a user's logged hours within a range of dates.
418
     *
419
     * @param int       $userId     Harvest user ID
420
     * @param \DateTime $startDate  beginning of date range
421
     * @param \DateTime $endDate    end of date range
422
     * @param bool      $billable   whether to return billable or non-billable hours
423
     * @param bool      $roundTime  whether to round hours as they're added
424
     *
425
     * @return float
426
     */
427
    public function getTotalUserHoursInRange($userId, $startDate, $endDate, $billable, $roundTime = false): float
428
    {
429
        $from  = $startDate->format('Y-m-d');
430
        $to    = $endDate->format('Y-m-d');
431
        $total = 0;
432
433
        $timeEntries = $this->collectPaginatedResults('time_entries?user_id=' . $userId . '&from=' . $from . '&to=' . $to);
434
435
        foreach ($timeEntries as $timeEntry)
436
        {
437
            if (($timeEntry->billable && $billable === true) || ($timeEntry->billable === false && $billable === false))
438
            {
439
                $roundedHours = $this->roundTime($timeEntry->hours, self::TIME_ROUNDING_METHOD);
440
441
                if ($roundTime)
442
                {
443
                    $total += $roundedHours * $timeEntry->billable_rate;
444
                }
445
                else
446
                {
447
                    $total += $timeEntry->hours * $timeEntry->billable_rate;
448
                }
449
            }
450
        }
451
452
        return $total;
453
    }
454
455
    /**
456
     * Calculate and return the grand total for all invoices on a given project.
457
     *
458
     * @param int $projectId  ID of relevant Harvest project
459
     *
460
     * @return float
461
     */
462
    public function getTotalProjectInvoiced($projectId): float
463
    {
464
        $invoices  = $this->getProjectInvoices($projectId);
465
        $total     = 0;
466
        $totalPaid = 0;
467
468
        foreach ($invoices as $invoice)
469
        {
470
            $total     += $invoice->amount;
471
            $paid       = $invoice->amount - $invoice->due_amount;
472
            $totalPaid += $paid;
473
        }
474
475
        return $total;
476
    }
477
478
    /**
479
     * Get uninvoiced billables for a given project.
480
     *
481
     * @param int     $projectId        ID of relevant Harvest project
482
     * @param boolean $includeExpenses  include billable project expenses?
483
     * @param boolean $includeTime      include logged billable time?
484
     * @param boolean $roundTime        round logged time before adding to the total?
485
     *
486
     * @return float
487
     */
488
    public function getTotalProjectUninvoiced($projectId, $includeExpenses = true, $includeTime = true, $roundTime = true): float
489
    {
490
        $total = 0;
491
492
        if ($includeExpenses)
493
        {
494
            $expenseEntries = $this->getProjectExpenses($projectId);
495
496
            foreach ($expenseEntries as $expense)
497
            {
498
                if ($expense->billable && ! $expense->is_billed)
499
                {
500
                    $total += $expense->total_cost;
501
                }
502
            }
503
        }
504
505
        if ($includeTime)
506
        {
507
            $timeEntries = $this->getProjectTimeEntries($projectId);
508
509
            foreach ($timeEntries as $timeEntry)
510
            {
511
                if ($timeEntry->billable && !$timeEntry->is_billed)
512
                {
513
                    $roundedHours = $this->roundTime($timeEntry->hours, self::TIME_ROUNDING_METHOD);
514
515
                    if ($roundTime)
516
                    {
517
                        $total += $roundedHours * $timeEntry->billable_rate;
518
                    }
519
                    else
520
                    {
521
                        $total += $timeEntry->hours * $timeEntry->billable_rate;
522
                    }
523
                }
524
            }
525
        }
526
527
        return $total;
528
    }
529
530
    /**
531
     * Total and return all costs for a given project.
532
     *
533
     * @param int     $projectId       Relevant Harvest project ID.
534
     * @param boolean $includeExpenses Calculate expenses toward total cost.
535
     * @param boolean $includeTime     Calculate logged time toward total cost.
536
     * @param boolean $roundTime       Round time when calculating its cost.
537
     *
538
     * @return float
539
     */
540
    public function getTotalProjectCosts($projectId, $includeExpenses = true, $includeTime = true, $roundTime = false): float
541
    {
542
        $total                = 0;
543
        $billableExpensesOnly = true;
544
        $billableTimeOnly     = true;
0 ignored issues
show
Unused Code introduced by
The assignment to $billableTimeOnly is dead and can be removed.
Loading history...
545
546
        if ($includeExpenses)
547
        {
548
            $expenseEntries = $this->getProjectExpenses($projectId);
549
550
            foreach ($expenseEntries as $expense)
551
            {
552
                if ($expense->billable || $billableExpensesOnly === false)
553
                {
554
                    $total += $expense->total_cost;
555
                }
556
            }
557
        }
558
559
        if ($includeTime)
560
        {
561
            $timeEntries = $this->getProjectTimeEntries($projectId);
562
563
            foreach ($timeEntries as $timeEntry)
564
            {
565
                if ($timeEntry->billable || $billableExpensesOnly === false)
566
                {
567
                    $roundedHours = $this->roundTime($timeEntry->hours, self::TIME_ROUNDING_METHOD);
568
569
                    if ($roundTime)
570
                    {
571
                        $timeCost = $roundedHours * $timeEntry->cost_rate;
572
                    }
573
                    else
574
                    {
575
                        $timeCost = $timeEntry->hours * $timeEntry->cost_rate;
576
                    }
577
578
                    $total += $timeCost;
579
                }
580
            }
581
        }
582
583
        return $total;
584
    }
585
586
587
    // Private Methods
588
    // =========================================================================
589
590
    /**
591
     * Make an API call and continue through any pagination.
592
     * Endpoint will be used as a cache key when caching is enabled.
593
     *
594
     * @param string $endpoint  API endpoint to be queried, which can include URL parameters but *not* `page=`
595
     * @param string $property  optional property that contains result object array (defaults to cleaned endpoint name)
596
     *
597
     * @return array
598
     */
599
    private function collectPaginatedResults($endpoint, $property = ''): array
600
    {
601
        $cacheKey = 'harvest_' . $endpoint;
602
603
        if (strpos($endpoint, '?') !== false)
604
        {
605
            $endpointPieces = explode('?', $endpoint);
606
            $endpointWithoutParameters = $endpointPieces[0];
607
            $endpoint .= '&page=';
608
        }
609
        else
610
        {
611
            $endpointWithoutParameters = $endpoint;
612
            $endpoint .= '?page=';
613
        }
614
615
        if ($property === '')
616
        {
617
            $property = $endpointWithoutParameters;
618
        }
619
620
        if (self::CACHE_ENABLED)
621
        {
622
            if ($cachedRecords = Craft::$app->cache->get($cacheKey))
623
            {
624
                return $cachedRecords;
625
            }
626
        }
627
628
        $fetchedAllRecords = false;
629
        $records           = [];
630
        $page              = 1;
631
632
        while ( ! $fetchedAllRecords)
633
        {
634
            $response = $this->client->get($endpoint . $page);
635
            $responseData = json_decode($response->getBody(), false);
636
637
            $records = array_merge($records, $responseData->{$property});
638
639
            if ($responseData->links->next)
640
            {
641
                $page++;
642
                continue;
643
            }
644
645
            $fetchedAllRecords = true;
646
        }
647
648
        if (self::CACHE_ENABLED)
649
        {
650
            Craft::$app->cache->set($cacheKey, $records, self::CACHE_SECONDS);
651
        }
652
653
        return $records;
654
    }
655
656
657
    /**
658
     * Round a decimal representing logged time in one of several interesting ways.
659
     *
660
     * @param float  $hours
661
     * @param string $method 'nextHalfHour', 'nearestHalfHour', 'nextWholeNumber', or 'nearestWholeNumber'
662
     *                       missing or invalid option will skip rounding
663
     *
664
     * @return float
665
     */
666
    private function roundTime($hours, $method = ''): float
667
    {
668
        if ($method === 'nextHalfHour')
669
        {
670
            // round up to nearest half hour
671
            return ceil($hours * 2) / 2;
672
        }
673
        elseif ($method === 'nearestHalfHour')
674
        {
675
            // round up or down to closest half hour
676
            return round($hours * 2) / 2;
677
        }
678
        elseif ($method === 'nextWholeNumber')
679
        {
680
            // round up to whole number
681
            return round($hours);
682
        }
683
        elseif ($method === 'nearestWholeNumber')
684
        {
685
            // round up or down to closest whole number
686
            return round($hours, 0, PHP_ROUND_HALF_EVEN);
687
        }
688
689
        return $hours;
690
    }
691
}
692