Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PandaDocService
0.00% covered (danger)
0.00%
0 / 113
0.00% covered (danger)
0.00%
0 / 12
992
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
 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 / 5
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 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 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
 inspectTemplate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listTemplates
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 downloadDocument
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 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 App\Domain\Document\Data\PandaDocDocumentData;
8use App\Domain\Document\Data\PandaDocDocumentRefData;
9use App\Domain\Document\Data\PandaDocSigningSessionData;
10use App\Domain\Document\Data\PandaDocTemplateDetailsData;
11use App\Support\Row;
12use RuntimeException;
13
14/**
15 * PandaDoc HTTP client.
16 *
17 * Implements {@see PandaDocClientInterface} so application code depends only
18 * on our DTOs and the interface. All PandaDoc wire-format details (field
19 * names, URL paths, auth headers) are confined to this class.
20 */
21final readonly class PandaDocService implements PandaDocClientInterface
22{
23    public function __construct(
24        private string $apiKey,
25        private string $baseUrl,
26    ) {}
27
28    public function getDocumentDetails(string $documentId): PandaDocDocumentData
29    {
30        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId) . '/details');
31
32        return PandaDocDocumentData::fromPandaDocResponse($response);
33    }
34
35    public function createSigningSession(string $documentId, string $recipientEmail): PandaDocSigningSessionData
36    {
37        $response = $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/session', [
38            'recipient' => $recipientEmail,
39            'lifetime' => 900,
40        ]);
41
42        return PandaDocSigningSessionData::fromPandaDocResponse($response);
43    }
44
45    public function resendDocument(string $documentId, ?string $message = null): void
46    {
47        $body = [
48            'message' => $message ?? 'Please review and sign this document.',
49            'silent' => false,
50        ];
51
52        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', $body);
53    }
54
55    public function createDocumentFromTemplate(
56        string $templateId,
57        string $documentName,
58        string $recipientEmail,
59        string $firstName,
60        string $lastName,
61        array $fields = [],
62        array $tokens = [],
63        string $recipientRole = 'Signer',
64        bool $requireIdVerification = false,
65    ): PandaDocDocumentRefData {
66        $recipient = [
67            'email' => $recipientEmail,
68            'first_name' => $firstName,
69            'last_name' => $lastName,
70            'role' => $recipientRole,
71        ];
72
73        // Gate signing behind a government-ID check. PandaDoc enforces this in
74        // the embedded signing flow, so a completed document is itself proof
75        // the recipient passed identity verification.
76        if ($requireIdVerification) {
77            $recipient['verification_settings'] = [
78                'verification_place' => 'before_sign',
79                'id_verification' => ['enabled' => true],
80            ];
81        }
82
83        $body = [
84            'name' => $documentName,
85            'template_uuid' => $templateId,
86            'recipients' => [$recipient],
87        ];
88
89        if ($fields !== []) {
90            $body['fields'] = $fields;
91        }
92
93        if ($tokens !== []) {
94            $body['tokens'] = array_map(
95                static fn(string $name, string $value) => ['name' => $name, 'value' => $value],
96                array_keys($tokens),
97                array_values($tokens),
98            );
99        }
100
101        $response = $this->makeRequest('POST', '/documents', $body);
102
103        return PandaDocDocumentRefData::fromPandaDocResponse($response);
104    }
105
106    public function getDocumentStatus(string $documentId): string
107    {
108        $response = $this->makeRequest('GET', '/documents/' . urlencode($documentId));
109
110        return Row::nullableString($response, 'status') ?? '';
111    }
112
113    public function sendDocument(string $documentId, string $message = 'Please review and sign this document.'): void
114    {
115        $this->makeRequest('POST', '/documents/' . urlencode($documentId) . '/send', [
116            'message' => $message,
117            'silent' => false,
118        ]);
119    }
120
121    public function inspectTemplate(string $templateId): PandaDocTemplateDetailsData
122    {
123        $response = $this->makeRequest('GET', '/templates/' . urlencode($templateId) . '/details');
124
125        return PandaDocTemplateDetailsData::fromPandaDocResponse($response);
126    }
127
128    public function listTemplates(): array
129    {
130        $response = $this->makeRequest('GET', '/templates?count=100');
131
132        $templates = [];
133        $results = $response['results'] ?? [];
134        if (is_array($results)) {
135            foreach ($results as $template) {
136                if (!is_array($template)) {
137                    continue;
138                }
139                $id = Row::nullableString($template, 'id');
140                $name = Row::nullableString($template, 'name');
141                if ($id !== null && $id !== '') {
142                    $templates[$id] = $name ?? '(unnamed)';
143                }
144            }
145        }
146
147        return $templates;
148    }
149
150    public function downloadDocument(string $documentId): string
151    {
152        $url = $this->baseUrl . '/documents/' . urlencode($documentId) . '/download';
153
154        $context = stream_context_create([
155            'http' => [
156                'method' => 'GET',
157                'header' => implode("\r\n", [
158                    'Authorization: API-Key ' . $this->apiKey,
159                    'Accept: application/pdf',
160                ]),
161                'ignore_errors' => true,
162            ],
163        ]);
164
165        $result = file_get_contents($url, false, $context);
166
167        if ($result === false) {
168            throw new RuntimeException('PandaDoc download failed: unable to connect to ' . $url);
169        }
170
171        /** @var array<int, string> $http_response_header */
172        $statusCode = $this->parseStatusCode($http_response_header);
173
174        if ($statusCode < 200 || $statusCode >= 300) {
175            throw new RuntimeException(
176                sprintf('PandaDoc download failed with status %d', $statusCode),
177            );
178        }
179
180        return $result;
181    }
182
183    /**
184     * Make an HTTP request to the PandaDoc API.
185     *
186     * @param array<string, mixed>|null $body
187     * @param string $method
188     * @param string $path
189     *
190     * @return array<mixed>
191     */
192    private function makeRequest(string $method, string $path, ?array $body = null): array
193    {
194        $url = $this->baseUrl . $path;
195
196        $headers = [
197            'Authorization: API-Key ' . $this->apiKey,
198            'Content-Type: application/json',
199            'Accept: application/json',
200        ];
201
202        $options = [
203            'http' => [
204                'method' => $method,
205                'header' => implode("\r\n", $headers),
206                'ignore_errors' => true,
207            ],
208        ];
209
210        if ($body !== null) {
211            $options['http']['content'] = json_encode($body, JSON_THROW_ON_ERROR);
212        }
213
214        $context = stream_context_create($options);
215        $result = file_get_contents($url, false, $context);
216
217        if ($result === false) {
218            throw new RuntimeException('PandaDoc API request failed: unable to connect to ' . $url);
219        }
220
221        /** @var array<int, string> $http_response_header */
222        $statusCode = $this->parseStatusCode($http_response_header);
223
224        if ($statusCode < 200 || $statusCode >= 300) {
225            throw new RuntimeException(
226                sprintf('PandaDoc API request failed with status %d: %s', $statusCode, $result),
227            );
228        }
229
230        if ($result === '') {
231            return [];
232        }
233
234        $decoded = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
235
236        if (!is_array($decoded)) {
237            throw new RuntimeException('PandaDoc API returned invalid JSON response');
238        }
239
240        return $decoded;
241    }
242
243    /**
244     * Parse HTTP status code from response headers.
245     *
246     * @param array<int, string> $headers
247     */
248    private function parseStatusCode(array $headers): int
249    {
250        foreach ($headers as $header) {
251            if (preg_match('/^HTTP\/[\d.]+ (\d{3})/', $header, $matches)) {
252                return (int)$matches[1];
253            }
254        }
255
256        return 0;
257    }
258}