Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PandaDocService
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 11
506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDocumentsForInvestor
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDocumentDetails
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createSigningSession
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 resendDocument
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 createDocumentFromTemplate
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 getDocumentStatus
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 sendDocument
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 transformDocument
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 makeRequest
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 parseStatusCode
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Document\Service;
6
7use RuntimeException;
8
9final readonly class PandaDocService
10{
11    public function __construct(
12        private string $apiKey,
13        private string $baseUrl,
14    ) {}
15
16    /**
17     * Get all documents associated with an investor's email.
18     *
19     * @param string $email
20     * @return array<int, array<string, mixed>>
21     */
22    public function getDocumentsForInvestor(string $email): array
23    {
24        $response = $this->makeRequest('GET', '/documents?q=' . urlencode($email) . '&order_by=date_created');
25
26        $results = $response['results'] ?? [];
27
28        return array_map([$this, 'transformDocument'], $results);
29    }
30
31    /**
32     * Get full details for a single document.
33     *
34     * @param string $documentId
35     * @return array<string, mixed>
36     */
37    public function getDocumentDetails(string $documentId): array
38    {
39        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId) . '/details');
40
41        return $this->transformDocument($response);
42    }
43
44    /**
45     * Create a signing session for a recipient.
46     *
47     * @param string $documentId
48     * @param string $recipientEmail
49     * @return array{sessionUrl: string, expiresAt: string}
50     */
51    public function createSigningSession(string $documentId, string $recipientEmail): array
52    {
53        $response = $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/session', [
54            'recipient' => $recipientEmail,
55            'lifetime' => 900,
56        ]);
57
58        return [
59            'sessionUrl' => (string)($response['id'] ?? ''),
60            'expiresAt' => (string)($response['expires_at'] ?? ''),
61        ];
62    }
63
64    /**
65     * Resend a document to its recipients.
66     * @param string $documentId
67     * @param ?string $message
68     */
69    public function resendDocument(string $documentId, ?string $message = null): void
70    {
71        $body = [
72            'message' => $message ?? 'Please review and sign this document.',
73            'silent' => false,
74        ];
75
76        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', $body);
77    }
78
79    /**
80     * Create a document from a PandaDoc template and assign a recipient.
81     *
82     * @param string $templateId
83     * @param string $documentName
84     * @param string $recipientEmail
85     * @param string $firstName
86     * @param string $lastName
87     * @param array<string, array{value: string}> $fields Field values keyed by field name
88     * @param array<string, string> $tokens Key-value pairs for template tokens
89     *
90     * @return array{id: string, status: string}
91     */
92    public function createDocumentFromTemplate(
93        string $templateId,
94        string $documentName,
95        string $recipientEmail,
96        string $firstName,
97        string $lastName,
98        array $fields = [],
99        array $tokens = [],
100    ): array {
101        $body = [
102            'name' => $documentName,
103            'template_uuid' => $templateId,
104            'recipients' => [
105                [
106                    'email' => $recipientEmail,
107                    'first_name' => $firstName,
108                    'last_name' => $lastName,
109                    'role' => 'Signer',
110                ],
111            ],
112        ];
113
114        if ($fields !== []) {
115            $body['fields'] = $fields;
116        }
117
118        if ($tokens !== []) {
119            $body['tokens'] = array_map(
120                static fn(string $name, string $value) => ['name' => $name, 'value' => $value],
121                array_keys($tokens),
122                array_values($tokens),
123            );
124        }
125
126        $response = $this->makeRequest('POST', '/documents', $body);
127
128        return [
129            'id' => (string)($response['id'] ?? ''),
130            'status' => (string)($response['status'] ?? ''),
131        ];
132    }
133
134    /**
135     * Get the current status of a document.
136     * @param string $documentId
137     */
138    public function getDocumentStatus(string $documentId): string
139    {
140        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId));
141
142        return (string)($response['status'] ?? '');
143    }
144
145    /**
146     * Send a draft document to its recipients for signing.
147     *
148     * @param string $documentId
149     * @param string $message Optional message to include in the email
150     */
151    public function sendDocument(string $documentId, string $message = 'Please review and sign this document.'): void
152    {
153        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', [
154            'message' => $message,
155            'silent' => false,
156        ]);
157    }
158
159    /**
160     * Transform a PandaDoc API document into the frontend shape.
161     *
162     * @param array<string, mixed> $doc
163     *
164     * @return array<string, mixed>
165     */
166    private function transformDocument(array $doc): array
167    {
168        $recipients = [];
169        foreach (($doc['recipients'] ?? []) as $recipient) {
170            $recipients[] = [
171                'email' => $recipient['email'] ?? '',
172                'firstName' => $recipient['first_name'] ?? '',
173                'lastName' => $recipient['last_name'] ?? '',
174                'role' => $recipient['role'] ?? '',
175                'signingOrder' => $recipient['signing_order'] ?? null,
176            ];
177        }
178
179        return [
180            'id' => $doc['id'],
181            'name' => $doc['name'],
182            'status' => $doc['status'],
183            'dateSent' => $doc['date_status_changed'] ?? null,
184            'dateCompleted' => $doc['date_completed'] ?? null,
185            'expirationDate' => $doc['expiration_date'] ?? null,
186            'createdAt' => $doc['date_created'],
187            'updatedAt' => $doc['date_modified'],
188            'recipients' => $recipients,
189            'signingUrl' => null,
190            'downloadUrl' => null,
191        ];
192    }
193
194    /**
195     * Make an HTTP request to the PandaDoc API.
196     *
197     * @param array<string, mixed>|null $body
198     * @param string $method
199     * @param string $path
200     *
201     * @return array<string, mixed>
202     */
203    private function makeRequest(string $method, string $path, ?array $body = null): array
204    {
205        $url = $this->baseUrl . $path;
206
207        $headers = [
208            'Authorization: API-Key ' . $this->apiKey,
209            'Content-Type: application/json',
210            'Accept: application/json',
211        ];
212
213        $options = [
214            'http' => [
215                'method' => $method,
216                'header' => implode("\r\n", $headers),
217                'ignore_errors' => true,
218            ],
219        ];
220
221        if ($body !== null) {
222            $options['http']['content'] = json_encode($body, JSON_THROW_ON_ERROR);
223        }
224        
225        $context = stream_context_create($options);
226        $result = file_get_contents($url, false, $context);
227
228        if ($result === false) {
229            throw new RuntimeException('PandaDoc API request failed: unable to connect to ' . $url);
230        }
231
232        /** @var array<int, string> $http_response_header */
233        $statusCode = $this->parseStatusCode($http_response_header);
234
235        if ($statusCode < 200 || $statusCode >= 300) {
236            throw new RuntimeException(
237                sprintf('PandaDoc API request failed with status %d: %s', $statusCode, $result),
238            );
239        }
240
241        if ($result === '') {
242            return [];
243        }
244
245        $decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
246
247        if (!is_array($decoded)) {
248            throw new RuntimeException('PandaDoc API returned invalid JSON response');
249        }
250
251        return $decoded;
252    }
253
254    /**
255     * Parse HTTP status code from response headers.
256     *
257     * @param array<int, string> $headers
258     */
259    private function parseStatusCode(array $headers): int
260    {
261        foreach ($headers as $header) {
262            if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $matches)) {
263                return (int)$matches[1];
264            }
265        }
266
267        return 0;
268    }
269}