Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CreateRegistrationDocumentsAction
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 6
272
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
 __invoke
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 createAndSendDocument
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 sendWhenDraftReady
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 saveDocument
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 updateDocumentStatus
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace App\Action\Document;
6
7use App\Domain\Auth\Data\UserAuthData;
8use App\Domain\Document\Data\DocumentTemplateData;
9use App\Domain\Document\Repository\DocumentTemplateRepository;
10use App\Domain\Document\Service\PandaDocClientInterface;
11use App\Domain\Document\Service\PandaDocFieldMapper;
12use App\Domain\Investor\Data\InvestorData;
13use App\Domain\Investor\Repository\InvestorRepository;
14use App\Renderer\JsonRenderer;
15use PDO;
16use Psr\Http\Message\ResponseInterface;
17use Psr\Http\Message\ServerRequestInterface;
18use RuntimeException;
19
20final readonly class CreateRegistrationDocumentsAction
21{
22    private const int SEND_POLL_ATTEMPTS = 10;
23
24    private const int SEND_POLL_DELAY_SECONDS = 2;
25
26    public function __construct(
27        private PandaDocClientInterface $pandaDocClient,
28        private DocumentTemplateRepository $templateRepository,
29        private InvestorRepository $investorRepository,
30        private PandaDocFieldMapper $fieldMapper,
31        private JsonRenderer $renderer,
32        private PDO $pdo,
33    ) {}
34
35    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
36    {
37        /** @var UserAuthData $user */
38        $user = $request->getAttribute('user');
39
40        if ($user->investorId === null) {
41            throw new RuntimeException('User is not linked to an investor profile');
42        }
43
44        $investor = $this->investorRepository->findInvestorById($user->investorId);
45        if ($investor === null) {
46            throw new RuntimeException('Investor not found');
47        }
48
49        $templates = $this->templateRepository->findActive();
50
51        $createdDocumentIds = [];
52        foreach ($templates as $template) {
53            $documentId = $this->createAndSendDocument($investor, $template, $user->email);
54            if ($documentId !== '') {
55                $createdDocumentIds[] = $documentId;
56            }
57        }
58
59        return $this->renderer->json($response, [
60            'success' => true,
61            'data' => [
62                'documentIds' => $createdDocumentIds,
63            ],
64        ]);
65    }
66
67    private function createAndSendDocument(
68        InvestorData $investor,
69        DocumentTemplateData $template,
70        string $recipientEmail,
71    ): string {
72        $mapping = $this->fieldMapper->map($investor, $template->templateId);
73
74        $documentName = sprintf(
75            '%s — %s %s',
76            $template->name,
77            $investor->firstName,
78            $investor->lastName,
79        );
80
81        $doc = $this->pandaDocClient->createDocumentFromTemplate(
82            templateId: $template->templateId,
83            documentName: $documentName,
84            recipientEmail: $recipientEmail,
85            firstName: $investor->firstName,
86            lastName: $investor->lastName,
87            fields: $mapping['fields'],
88            tokens: $mapping['tokens'],
89            recipientRole: $template->recipientRole,
90        );
91
92        if ($doc->id === '') {
93            return '';
94        }
95
96        $this->saveDocument($investor->investorId, $doc->id, $documentName, $template->templateId);
97        $this->sendWhenDraftReady($doc->id);
98
99        return $doc->id;
100    }
101
102    /**
103     * PandaDoc starts a new document in `document.uploaded` and transitions to
104     * `document.draft` once template processing finishes. We poll briefly and
105     * then send. Failure to reach draft is non-fatal — admins can resend later.
106     * @param string $pandadocId
107     */
108    private function sendWhenDraftReady(string $pandadocId): void
109    {
110        for ($attempt = 0; $attempt < self::SEND_POLL_ATTEMPTS; $attempt++) {
111            sleep(self::SEND_POLL_DELAY_SECONDS);
112            $status = $this->pandaDocClient->getDocumentStatus($pandadocId);
113            if ($status !== 'document.draft') {
114                continue;
115            }
116
117            try {
118                $this->pandaDocClient->sendDocument($pandadocId);
119                $this->updateDocumentStatus($pandadocId, 'document.sent');
120            } catch (RuntimeException) {
121                // Send failed despite draft status; leave as draft for retry.
122            }
123
124            return;
125        }
126    }
127
128    private function saveDocument(int $investorId, string $pandadocId, string $documentName, string $templateId): void
129    {
130        $stmt = $this->pdo->prepare(
131            'INSERT INTO investor_documents (investor_id, pandadoc_id, document_name, template_id, status)
132             VALUES (:investorId, :pandadocId, :documentName, :templateId, :status)',
133        );
134
135        if ($stmt === false) {
136            throw new RuntimeException('Failed to prepare statement');
137        }
138
139        $stmt->execute([
140            'investorId' => $investorId,
141            'pandadocId' => $pandadocId,
142            'documentName' => $documentName,
143            'templateId' => $templateId,
144            'status' => 'document.draft',
145        ]);
146    }
147
148    private function updateDocumentStatus(string $pandadocId, string $status): void
149    {
150        $stmt = $this->pdo->prepare(
151            'UPDATE investor_documents SET status = :status, updated_at = CURRENT_TIMESTAMP WHERE pandadoc_id = :pandadocId',
152        );
153
154        if ($stmt === false) {
155            throw new RuntimeException('Failed to prepare statement');
156        }
157
158        $stmt->execute(['status' => $status, 'pandadocId' => $pandadocId]);
159    }
160}