assinafy 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,912 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ module Resources
5
+ # Document upload, retrieval, download, lifecycle, and verification.
6
+ #
7
+ # See https://api.assinafy.com.br/v1/docs#document for the full
8
+ # documentation of these endpoints.
9
+ class DocumentResource < BaseResource
10
+ MAX_UPLOAD_BYTES = 25 * 1024 * 1024
11
+ READY_STATUSES = %w[metadata_ready pending_signature certificated].freeze
12
+ FAILED_STATUSES = %w[failed rejected_by_signer rejected_by_user expired].freeze
13
+ ARTIFACT_TYPES = %w[original certificated certificate-page bundle pades].freeze
14
+
15
+ # Upload a PDF and create a document.
16
+ #
17
+ # @param source [String, Hash] either a path to a PDF on disk, or a
18
+ # Hash with `:file_path` (path) **or** `:buffer` + `:file_name` (raw bytes).
19
+ # @param options [Hash]
20
+ # @option options [String] :name optional display name for the document
21
+ # @option options [String] :account_id override the client default
22
+ # @return [Hash] document object
23
+ # @raise [Assinafy::ValidationError] on invalid input or empty/non-PDF file
24
+ # @raise [Assinafy::ApiError] on a non-2xx response
25
+ #
26
+ # @see POST /accounts/{account_id}/documents
27
+ # @example Upload a PDF from disk
28
+ # # Request: POST /accounts/{account_id}/documents (multipart/form-data)
29
+ # # Body: file=<binary application/pdf>, name="assinafy_audit_test.pdf"
30
+ # client.documents.upload('/tmp/contract.pdf', name: 'assinafy_audit_test.pdf')
31
+ #
32
+ # # Response (unwrapped data payload):
33
+ # {
34
+ # 'resource' => 'document',
35
+ # 'id' => '1032009d72b364f377ff270405cc',
36
+ # 'account_id' => 'account-id',
37
+ # 'template_id' => nil,
38
+ # 'name' => 'assinafy_audit_test.pdf',
39
+ # 'status' => 'uploaded',
40
+ # 'artifacts' => {
41
+ # 'original' => 'https://sandbox.assinafy.com.br/v1/documents/<id>/download/original'
42
+ # },
43
+ # 'is_closed' => false,
44
+ # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1032009d72b364f377ff270405cc',
45
+ # 'decline_reason' => nil,
46
+ # 'declined_by' => nil,
47
+ # 'tags' => [],
48
+ # 'created_at' => '2026-06-05T21:21:12Z',
49
+ # 'updated_at' => '2026-06-05T21:21:13Z',
50
+ # 'pages' => []
51
+ # }
52
+ # @example Upload raw bytes from memory
53
+ # client.documents.upload(buffer: pdf_bytes, file_name: 'in_memory.pdf')
54
+ # @note The SDK enforces the `.pdf` extension and 25 MB limit; the API
55
+ # performs authoritative PDF-structure validation.
56
+ def upload(source, options = {})
57
+ buffer, file_name = read_source(source, max_bytes: MAX_UPLOAD_BYTES)
58
+ validate_upload!(buffer, file_name)
59
+
60
+ acc_id = account_id(options[:account_id])
61
+
62
+ @logger.info("Uploading document (#{buffer.bytesize} bytes)")
63
+
64
+ payload = { file: file_part(buffer, file_name, 'application/pdf') }
65
+ payload[:name] = options[:name] if options[:name]
66
+
67
+ document = call('Document upload failed') do
68
+ http_post("accounts/#{acc_id}/documents", payload)
69
+ end
70
+
71
+ unless document.is_a?(Hash) && document['id']
72
+ raise ValidationError.new('Upload succeeded but no document ID was returned')
73
+ end
74
+
75
+ @logger.info("Document uploaded: #{document['id']}")
76
+ document
77
+ end
78
+
79
+ # List documents for an account.
80
+ #
81
+ # @param params [Hash] query parameters (`status`, `method`, `search`, `sort`, `tags`, `page`, `per_page`)
82
+ # @param account_id_override [String, nil]
83
+ # @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: { current_page:, per_page:, total:, last_page: } }`
84
+ #
85
+ # @see GET /accounts/{account_id}/documents
86
+ # @example List the first page of documents
87
+ # # Request: GET /accounts/{account_id}/documents?per-page=3
88
+ # client.documents.list(per_page: 3)
89
+ #
90
+ # # Response (unwrapped data payload):
91
+ # {
92
+ # data: [
93
+ # {
94
+ # 'id' => '1031ff847e1aecdcf848f579cc77',
95
+ # 'account_id' => 'account-id',
96
+ # 'template_id' => nil,
97
+ # 'name' => 'audit.pdf',
98
+ # 'status' => 'metadata_ready',
99
+ # 'artifacts' => {
100
+ # 'original' => 'https://sandbox.assinafy.com.br/v1/documents/1031ff84.../download/original',
101
+ # 'thumbnail' => 'https://sandbox.assinafy.com.br/v1/documents/1031ff84.../thumbnail'
102
+ # },
103
+ # 'is_closed' => false,
104
+ # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1031ff847e1aecdcf848f579cc77',
105
+ # 'decline_reason' => nil,
106
+ # 'declined_by' => nil,
107
+ # 'tags' => [],
108
+ # 'assignment' => nil,
109
+ # 'pages' => [
110
+ # { 'id' => '1031ff84c23f85c38503ff0324d6', 'number' => 1, 'height' => 1651,
111
+ # 'width' => 1275, 'download_url' => 'https://sandbox.assinafy.com.br/v1/documents/.../download' }
112
+ # ],
113
+ # 'created_at' => '2026-06-05T20:50:31Z',
114
+ # 'updated_at' => '2026-06-05T20:50:34Z'
115
+ # }
116
+ # # ... (one Hash per document)
117
+ # ],
118
+ # meta: { current_page: 1, per_page: 3, total: 14, last_page: 5 }
119
+ # }
120
+ def list(params = {}, account_id_override = nil)
121
+ acc_id = account_id(account_id_override)
122
+
123
+ call_list('Failed to list documents') do
124
+ http_get("accounts/#{acc_id}/documents", params)
125
+ end
126
+ end
127
+
128
+ # Lightweight search over an account's documents (id/name/status/artifacts),
129
+ # without the heavier per-document detail returned by {#list}.
130
+ #
131
+ # @param query [String] free-text search term
132
+ # @param params [Hash] extra query parameters (`status`, `page`, `per_page`, ...)
133
+ # @param account_id_override [String, nil]
134
+ # @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: {..} | nil }`
135
+ # @see GET /accounts/{account_id}/documents/search
136
+ # @example Search documents by name
137
+ # # Request: GET /accounts/{account_id}/documents/search?search=contract
138
+ # client.documents.search('contract')
139
+ #
140
+ # # Response (unwrapped data payload):
141
+ # {
142
+ # data: [
143
+ # {
144
+ # 'id' => '103b0253fdc3607d342c49f9b55d',
145
+ # 'account_id' => 'account-id',
146
+ # 'template_id' => nil,
147
+ # 'name' => 'contract.pdf',
148
+ # 'status' => 'metadata_ready',
149
+ # 'artifacts' => { 'original' => 'https://...', 'thumbnail' => 'https://...' },
150
+ # 'is_closed' => false,
151
+ # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/103b0253...',
152
+ # 'tags' => [],
153
+ # 'created_at' => '2026-07-20T15:53:37Z',
154
+ # 'updated_at' => '2026-07-20T15:53:41Z'
155
+ # }
156
+ # # ... (one Hash per matching document)
157
+ # ],
158
+ # meta: nil
159
+ # }
160
+ def search(query, params = {}, account_id_override = nil)
161
+ acc_id = account_id(account_id_override)
162
+
163
+ call_list('Failed to search documents') do
164
+ http_get("accounts/#{acc_id}/documents/search", params.merge(search: query))
165
+ end
166
+ end
167
+
168
+ # List the catalog of document status codes.
169
+ #
170
+ # @return [Array<Hash>] each entry has `code` and a `deletable` flag
171
+ # @see GET /documents/statuses
172
+ # @example List status codes
173
+ # # Request: GET /documents/statuses
174
+ # client.documents.statuses
175
+ #
176
+ # # Response (unwrapped data payload):
177
+ # [
178
+ # { 'code' => 'uploading', 'deletable' => false },
179
+ # { 'code' => 'uploaded', 'deletable' => false },
180
+ # { 'code' => 'metadata_processing', 'deletable' => false },
181
+ # { 'code' => 'metadata_ready', 'deletable' => true },
182
+ # { 'code' => 'expired', 'deletable' => true },
183
+ # { 'code' => 'certificating', 'deletable' => false },
184
+ # { 'code' => 'certificated', 'deletable' => false },
185
+ # { 'code' => 'rejected_by_signer', 'deletable' => true },
186
+ # { 'code' => 'pending_signature', 'deletable' => true },
187
+ # { 'code' => 'rejected_by_user', 'deletable' => true },
188
+ # { 'code' => 'failed', 'deletable' => true }
189
+ # ]
190
+ def statuses
191
+ call('Failed to list document statuses') do
192
+ http_get('documents/statuses')
193
+ end
194
+ end
195
+
196
+ # Fetch a document by ID.
197
+ #
198
+ # @param document_id [String]
199
+ # @return [Hash] document object (includes `assignment` once one exists, else nil)
200
+ # @see GET /documents/{document_id}
201
+ # @example Fetch a document
202
+ # # Request: GET /documents/{document_id}
203
+ # client.documents.details('1032009d72b364f377ff270405cc')
204
+ #
205
+ # # Response (unwrapped data payload):
206
+ # {
207
+ # 'resource' => 'document',
208
+ # 'id' => '1032009d72b364f377ff270405cc',
209
+ # 'account_id' => 'account-id',
210
+ # 'template_id' => nil,
211
+ # 'name' => 'assinafy_audit_test.pdf',
212
+ # 'status' => 'metadata_ready',
213
+ # 'artifacts' => {
214
+ # 'original' => 'https://sandbox.assinafy.com.br/v1/documents/1032009d.../download/original',
215
+ # 'thumbnail' => 'https://sandbox.assinafy.com.br/v1/documents/1032009d.../thumbnail'
216
+ # },
217
+ # 'is_closed' => false,
218
+ # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/1032009d72b364f377ff270405cc',
219
+ # 'decline_reason' => nil,
220
+ # 'declined_by' => nil,
221
+ # 'tags' => [],
222
+ # 'assignment' => nil,
223
+ # 'pages' => [
224
+ # { 'id' => '1032009db961c327b101a7fea34d', 'number' => 1, 'height' => 1651,
225
+ # 'width' => 1275, 'download_url' => 'https://sandbox.assinafy.com.br/v1/documents/.../download' }
226
+ # ],
227
+ # 'created_at' => '2026-06-05T21:21:12Z',
228
+ # 'updated_at' => '2026-06-05T21:21:15Z'
229
+ # }
230
+ def details(document_id)
231
+ doc_id = require_id(document_id, 'Document ID')
232
+
233
+ call('Failed to fetch document details') do
234
+ http_get("documents/#{doc_id}")
235
+ end
236
+ end
237
+
238
+ alias get details
239
+
240
+ # Rename a document.
241
+ #
242
+ # @param document_id [String]
243
+ # @param name [String] the new display name
244
+ # @return [Hash] the updated document object (envelope `data` unwrapped)
245
+ # @see PATCH /documents/{document_id}
246
+ # @example Rename a document
247
+ # # Request: PATCH /documents/{document_id}
248
+ # # Body: { "name": "renamed.pdf" }
249
+ # client.documents.rename('103b0253fdc3607d342c49f9b55d', 'renamed.pdf')
250
+ #
251
+ # # Response (unwrapped data payload):
252
+ # {
253
+ # 'resource' => 'document',
254
+ # 'id' => '103b0253fdc3607d342c49f9b55d',
255
+ # 'account_id' => 'account-id',
256
+ # 'name' => 'renamed.pdf',
257
+ # 'status' => 'metadata_ready',
258
+ # 'artifacts' => { 'original' => 'https://...', 'thumbnail' => 'https://...' },
259
+ # 'is_closed' => false,
260
+ # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/103b0253...',
261
+ # 'tags' => [],
262
+ # 'created_at' => '2026-07-20T15:53:37Z',
263
+ # 'updated_at' => '2026-07-20T15:53:41Z'
264
+ # # ... (see #details for the full document shape)
265
+ # }
266
+ def rename(document_id, name)
267
+ doc_id = require_id(document_id, 'Document ID')
268
+ new_name = require_present(name, 'Name')
269
+
270
+ call('Failed to rename document') do
271
+ http_patch("documents/#{doc_id}", body_params(name: new_name))
272
+ end
273
+ end
274
+
275
+ # Poll {#details} until the document reaches a {READY_STATUSES ready} status,
276
+ # raising if it reaches a {FAILED_STATUSES failed} status or the deadline elapses.
277
+ #
278
+ # @param document_id [String]
279
+ # @param max_wait_seconds [Integer] total deadline (default: 30)
280
+ # @param poll_interval_seconds [Integer] (default: 2)
281
+ # @return [Hash] the document once it is ready
282
+ # @raise [Assinafy::ValidationError] on timeout or terminal failed status
283
+ # @example Block until a freshly uploaded document is processed
284
+ # # Polls GET /documents/{document_id} every 2s until status is ready.
285
+ # client.documents.wait_until_ready('1032009d72b364f377ff270405cc', max_wait_seconds: 30)
286
+ #
287
+ # # Response (unwrapped data payload): same shape as #details, with a ready status:
288
+ # {
289
+ # 'resource' => 'document',
290
+ # 'id' => '1032009d72b364f377ff270405cc',
291
+ # 'status' => 'metadata_ready', # one of READY_STATUSES
292
+ # # ... (see #details for the full document shape)
293
+ # }
294
+ def wait_until_ready(document_id, max_wait_seconds: 30, poll_interval_seconds: 2)
295
+ doc_id = require_id(document_id, 'Document ID')
296
+ unless max_wait_seconds.is_a?(Numeric) && max_wait_seconds > 0 &&
297
+ poll_interval_seconds.is_a?(Numeric) && poll_interval_seconds > 0
298
+ raise ValidationError.new('Wait and poll intervals must be positive numbers')
299
+ end
300
+
301
+ clock = Process::CLOCK_MONOTONIC
302
+ deadline = Process.clock_gettime(clock) + max_wait_seconds
303
+ attempts = 0
304
+
305
+ @logger.info("Waiting for document to be ready: #{doc_id}")
306
+
307
+ while Process.clock_gettime(clock) < deadline
308
+ attempts += 1
309
+ begin
310
+ doc = details(doc_id)
311
+ status = doc['status'] || 'unknown'
312
+
313
+ @logger.debug("Document status check #{attempts}: #{status}")
314
+
315
+ return doc if READY_STATUSES.include?(status)
316
+
317
+ if FAILED_STATUSES.include?(status)
318
+ raise ValidationError.new("Document processing failed with status: #{status}")
319
+ end
320
+ rescue NetworkError => e
321
+ @logger.warn("Error checking document status: #{e.message}")
322
+ end
323
+
324
+ remaining = deadline - Process.clock_gettime(clock)
325
+ break unless remaining > 0
326
+
327
+ sleep([poll_interval_seconds, remaining].min)
328
+ end
329
+
330
+ raise ValidationError.new(
331
+ 'Timeout waiting for document to be ready',
332
+ { document_id: doc_id, attempts: attempts }
333
+ )
334
+ end
335
+
336
+ # Download a document artifact as raw bytes.
337
+ #
338
+ # @param document_id [String]
339
+ # @param artifact_name [String] `original`, `certificated`, `certificate-page`, `pades`, or
340
+ # `bundle` (default `certificated`)
341
+ # @return [String] binary PDF body
342
+ # @raise [Assinafy::ValidationError] on unknown artifact type
343
+ # @see GET /documents/{document_id}/download/{artifact_name}
344
+ # @example Download the original upload and save it to disk
345
+ # # Request: GET /documents/{document_id}/download/original
346
+ # bytes = client.documents.download('1032009d72b364f377ff270405cc', 'original')
347
+ #
348
+ # # Response: raw bytes of the PDF (NOT a JSON envelope), e.g. a 607-byte String:
349
+ # bytes.class # => String
350
+ # bytes.bytesize # => 607
351
+ # File.binwrite('original.pdf', bytes)
352
+ def download(document_id, artifact_name = 'certificated')
353
+ doc_id = require_id(document_id, 'Document ID')
354
+ art = artifact_type(artifact_name)
355
+
356
+ call_binary('Failed to download document') do
357
+ http_get("documents/#{doc_id}/download/#{art}")
358
+ end
359
+ end
360
+
361
+ # Download the document thumbnail (PNG/JPEG bytes).
362
+ #
363
+ # @param document_id [String]
364
+ # @return [String] binary image body
365
+ # @see GET /documents/{document_id}/thumbnail
366
+ # @example Download the thumbnail and save it
367
+ # # Request: GET /documents/{document_id}/thumbnail
368
+ # bytes = client.documents.thumbnail('1032009d72b364f377ff270405cc')
369
+ #
370
+ # # Response: raw image bytes (NOT a JSON envelope), e.g. a 4973-byte JPEG String:
371
+ # bytes.class # => String
372
+ # bytes.byteslice(0, 4) # => "\xFF\xD8\xFF\xE0" (JPEG magic bytes)
373
+ # File.binwrite('thumb.jpg', bytes)
374
+ def thumbnail(document_id)
375
+ doc_id = require_id(document_id, 'Document ID')
376
+
377
+ call_binary('Failed to download document thumbnail') do
378
+ http_get("documents/#{doc_id}/thumbnail")
379
+ end
380
+ end
381
+
382
+ # Download a single page artifact.
383
+ #
384
+ # @param document_id [String]
385
+ # @param page_id [String]
386
+ # @return [String] binary image body
387
+ # @see GET /documents/{document_id}/pages/{page_id}/download
388
+ # @example Download a single page image
389
+ # # Request: GET /documents/{document_id}/pages/{page_id}/download
390
+ # bytes = client.documents.download_page('1032009d72b364f377ff270405cc',
391
+ # '1032009db961c327b101a7fea34d')
392
+ #
393
+ # # Response: raw image bytes (NOT a JSON envelope):
394
+ # bytes.class # => String
395
+ # File.binwrite('page-1.png', bytes)
396
+ def download_page(document_id, page_id)
397
+ doc_id = require_id(document_id, 'Document ID')
398
+ pid = require_id(page_id, 'Page ID')
399
+
400
+ call_binary('Failed to download page') do
401
+ http_get("documents/#{doc_id}/pages/#{pid}/download")
402
+ end
403
+ end
404
+
405
+ # List the activity log for a document.
406
+ #
407
+ # @param document_id [String]
408
+ # @return [Array<Hash>] newest-first activity entries (empty Array when there are none)
409
+ # @see GET /documents/{documentId}/activities
410
+ # @example List a document's activity log
411
+ # # Request: GET /documents/{document_id}/activities
412
+ # client.documents.activities('1032009d72b364f377ff270405cc')
413
+ #
414
+ # # Response (unwrapped data payload):
415
+ # [
416
+ # {
417
+ # 'id' => 8304,
418
+ # 'event' => 'document_metadata_ready',
419
+ # 'message' => 'Documento processado.',
420
+ # 'payload' => [],
421
+ # 'origin' => nil,
422
+ # 'created_at' => '2026-06-05T21:21:15Z'
423
+ # },
424
+ # {
425
+ # 'id' => 8303,
426
+ # 'event' => 'document_uploaded',
427
+ # 'message' => 'Documento criado.',
428
+ # 'payload' => [],
429
+ # 'origin' => { 'ip' => '99.75.13.162', 'user-agent' => 'assinafy-ruby-sdk/1.3.1' },
430
+ # 'created_at' => '2026-06-05T21:21:13Z'
431
+ # }
432
+ # ]
433
+ def activities(document_id)
434
+ doc_id = require_id(document_id, 'Document ID')
435
+
436
+ result = call('Failed to fetch document activities') do
437
+ http_get("documents/#{doc_id}/activities")
438
+ end
439
+
440
+ result || []
441
+ end
442
+
443
+ # Permanently delete a document. Only allowed for "deletable" statuses.
444
+ #
445
+ # @param document_id [String]
446
+ # @return [nil]
447
+ # @see DELETE /documents/{documentId}
448
+ # @example Delete a deletable document
449
+ # # Request: DELETE /documents/{document_id}
450
+ # client.documents.delete('1032009d72b364f377ff270405cc')
451
+ #
452
+ # # Response: the API returns { "status": 200, "data": [] }; the SDK returns nil.
453
+ # # => nil
454
+ def delete(document_id)
455
+ doc_id = require_id(document_id, 'Document ID')
456
+
457
+ call_void('Failed to delete document') do
458
+ http_delete("documents/#{doc_id}")
459
+ end
460
+ end
461
+
462
+ # Create a document from a template, optionally creating its virtual assignment.
463
+ #
464
+ # @param template_id [String]
465
+ # @param signers_or_payload [Array<Hash>, Hash] signer references (`role_id` + `id`)
466
+ # or a full payload Hash. When a Hash is passed, `options` is merged into it.
467
+ # @param options [Hash] additional body fields (`name`, `message`,
468
+ # `editor_fields`, `expires_at`, ...)
469
+ # @param account_id_override [String, nil]
470
+ # @return [Hash] document object with an embedded `assignment` (signers, items, signing_urls)
471
+ #
472
+ # @see POST /accounts/{account_id}/templates/{template_id}/documents
473
+ # @example Create a document from a template with two signers
474
+ # # Request: POST /accounts/{account_id}/templates/{template_id}/documents
475
+ # # Body: {
476
+ # # "name": "sample-contract.pdf",
477
+ # # "message": "Message to the signers",
478
+ # # "signers": [
479
+ # # { "role_id": "fa8c14f3...", "id": "fa8c140c...", "verification_method": "Email",
480
+ # # "notification_methods": ["Email"], "step": 1 }
481
+ # # ],
482
+ # # "expires_at": "2024-07-30T23:59:00Z"
483
+ # # }
484
+ # client.documents.create_from_template(
485
+ # '60f720572d7fecf7c16c8463',
486
+ # [{ role_id: 'fa8c14f3...', id: 'fa8c140c...' }],
487
+ # name: 'sample-contract.pdf', message: 'Message to the signers'
488
+ # )
489
+ #
490
+ # # Response (unwrapped data payload):
491
+ # {
492
+ # 'resource' => 'document',
493
+ # 'id' => 'fa8c140c614c928f7e7efa086b2',
494
+ # 'account_id' => '1a',
495
+ # 'template_id' => 'fa8c140b5ee344f8e48236ed284',
496
+ # 'name' => 'sample-contract.pdf',
497
+ # 'status' => 'uploaded',
498
+ # 'assignment' => {
499
+ # 'id' => 'fa8c140ccd5781b079738d19e95',
500
+ # 'method' => 'virtual',
501
+ # 'signers' => [{ 'id' => 'fa8c140c...', 'full_name' => 'Suzana Cordeiro',
502
+ # 'email' => 'signer@example.com', 'has_accepted_terms' => false }],
503
+ # 'summary' => { 'signer_count' => 1, 'completed_count' => 0, 'signers' => [] },
504
+ # 'signing_urls' => [{ 'signer_id' => 'customid1', 'url' => 'https://.../sign/...' }]
505
+ # # ... (also items, copy_receivers, expires_at, message)
506
+ # },
507
+ # 'tags' => [{ 'id' => 'ab12cd34...', 'name' => 'Onboarding' }],
508
+ # 'created_at' => '2024-07-23T15:05:17Z',
509
+ # 'updated_at' => '2024-07-23T15:05:17Z'
510
+ # # ... (artifacts, pages, is_closed, decline_reason; see docs for full shape)
511
+ # }
512
+ def create_from_template(template_id, signers_or_payload, options = {}, account_id_override = nil)
513
+ tmpl_id = require_id(template_id, 'Template ID')
514
+ acc_id = account_id(account_id_override)
515
+ body = template_body(signers_or_payload, options)
516
+
517
+ @logger.info("Creating document from template #{tmpl_id} for account #{acc_id}")
518
+
519
+ call('Failed to create document from template') do
520
+ http_post("accounts/#{acc_id}/templates/#{tmpl_id}/documents", body)
521
+ end
522
+ end
523
+
524
+ # Estimate the cost of creating a document from a template without consuming credits.
525
+ #
526
+ # @param template_id [String]
527
+ # @param signers_or_payload [Array<Hash>, Hash] signers with required +role_id+ and
528
+ # optional +verification_method+ / +notification_methods+; signer IDs are not required
529
+ # @param account_id_override [String, nil]
530
+ # @return [Hash] cost breakdown with current account balances
531
+ #
532
+ # @see POST /accounts/{account_id}/templates/{template_id}/documents/estimate-cost
533
+ # @example Estimate cost before creating from a template
534
+ # # Request: POST /accounts/{account_id}/templates/{template_id}/documents/estimate-cost
535
+ # # Body: { "signers": [{ "role_id": "fa8c14f3...", "notification_methods": ["Email"] }] }
536
+ # client.documents.estimate_cost_from_template(
537
+ # '60f720572d7fecf7c16c8463',
538
+ # [{ role_id: 'fa8c14f3...', notification_methods: ['Email'] }]
539
+ # )
540
+ #
541
+ # # Response (unwrapped data payload):
542
+ # {
543
+ # 'documents' => 1,
544
+ # 'credits' => 0,
545
+ # 'needs_extra_document' => false,
546
+ # 'extra_document_cost' => 0,
547
+ # 'total_credits' => 0,
548
+ # 'breakdown' => [], # [{ 'code'=>'NotificationWhatsapp', 'cost'=>0.45, 'quantity'=>1, ... }]
549
+ # 'document_balance' => 62,
550
+ # 'credit_balance' => 0,
551
+ # 'has_sufficient_resources' => true,
552
+ # 'blocking_reason' => nil, # e.g. 'PendingPayment' / 'InsufficientDocuments' when blocked
553
+ # 'message' => nil
554
+ # }
555
+ def estimate_cost_from_template(template_id, signers_or_payload, account_id_override = nil)
556
+ tmpl_id = require_id(template_id, 'Template ID')
557
+ acc_id = account_id(account_id_override)
558
+ body = template_body(signers_or_payload)
559
+
560
+ call('Failed to estimate cost from template') do
561
+ http_post("accounts/#{acc_id}/templates/#{tmpl_id}/documents/estimate-cost", body)
562
+ end
563
+ end
564
+
565
+ # Verify a certificated document by its signature hash.
566
+ #
567
+ # @param hash [String]
568
+ # @return [Hash] verification result; `is_valid` is false when the hash is unknown
569
+ # @see GET /documents/{signature_hash}/verify
570
+ # @example Verify a certificated document
571
+ # # Request: GET /documents/{signature_hash}/verify
572
+ # client.documents.verify('FE32EDDADE7CBDDCBB934E7402047450B0E59C02')
573
+ #
574
+ # # Response (unwrapped data payload) - verified:
575
+ # {
576
+ # 'hash' => 'FE32EDDADE7CBDDCBB934E7402047450B0E59C02',
577
+ # 'id' => '63ddb172402799bfc991d10d',
578
+ # 'status' => 'certificated',
579
+ # 'page_count' => '1',
580
+ # 'signer_count' => '1',
581
+ # 'completed_count' => 1,
582
+ # 'completed_at' => '2023-01-27T19:27:44Z',
583
+ # 'verified_at' => '2023-01-27T19:27:46Z',
584
+ # 'is_valid' => true,
585
+ # 'message' => ''
586
+ # }
587
+ # # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, 'status' => nil,
588
+ # # 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
589
+ def verify(hash)
590
+ h = require_id(hash, 'Signature hash')
591
+
592
+ call('Failed to verify document') do
593
+ http_get("documents/#{h}/verify")
594
+ end
595
+ end
596
+
597
+ # Fetch the unauthenticated, public-facing metadata of a document. The
598
+ # OpenAPI declares the full Document schema, while the current sandbox
599
+ # returns the smaller payload shown below; the SDK passes either through.
600
+ #
601
+ # @param document_id [String]
602
+ # @return [Hash] a Document Hash, or the current sandbox's minimal metadata Hash
603
+ # @see GET /public/documents/{document_id}
604
+ # @example Fetch public-facing document info (no auth required)
605
+ # # Request: GET /public/documents/{document_id}
606
+ # client.documents.public_info('39adfe3r5a3a')
607
+ #
608
+ # # Response (unwrapped data payload):
609
+ # {
610
+ # 'resource' => 'document',
611
+ # 'id' => 'doc1',
612
+ # 'name' => '1.pdf',
613
+ # 'page_count' => '1',
614
+ # 'created_by' => 'John Smith'
615
+ # }
616
+ def public_info(document_id)
617
+ doc_id = require_id(document_id, 'Document ID')
618
+
619
+ call('Failed to fetch public document info') do
620
+ http_get("public/documents/#{doc_id}")
621
+ end
622
+ end
623
+
624
+ # Send a 6-digit access token for the document to a signer (public endpoint).
625
+ # The current OpenAPI permits no body or an `{ email: }` body, while the
626
+ # deployed sandbox requires `{ recipient:, channel: }` when a recipient is supplied.
627
+ #
628
+ # @param document_id [String]
629
+ # @param recipient [String, nil] deployed-API email address or WhatsApp phone number
630
+ # @param channel [String, nil] deployed-API `email` or `whatsapp` channel
631
+ # @param email [String, nil] email address for the current OpenAPI request shape
632
+ # @return [nil, Hash] `nil` for the OpenAPI's no-data envelope; the current
633
+ # sandbox returns `{ 'document' => {..}, 'channel' => String, 'recipient' => String }`
634
+ # @see PUT /public/documents/{document_id}/send-token
635
+ # @example Ask the API to use the document's signer contact (no auth required)
636
+ # client.documents.send_token('document-id')
637
+ #
638
+ # @example Email a signer using the current OpenAPI request (no auth required)
639
+ # # Request: PUT /public/documents/{document_id}/send-token
640
+ # # Body: { "email": "signer@example.com" }
641
+ # client.documents.send_token('document-id', email: 'signer@example.com')
642
+ #
643
+ # @example Use the current sandbox request shape
644
+ # # Body: { "recipient": "signer@example.com", "channel": "email" }
645
+ # client.documents.send_token('document-id', recipient: 'signer@example.com', channel: 'email')
646
+ #
647
+ # # Current sandbox response (unwrapped data payload):
648
+ # {
649
+ # 'document' => {
650
+ # 'resource' => 'document',
651
+ # 'id' => 'doc1',
652
+ # 'name' => '1.pdf',
653
+ # 'page_count' => '1',
654
+ # 'created_by' => 'John Smith'
655
+ # },
656
+ # 'channel' => 'email',
657
+ # 'recipient' => 'signer@example.com'
658
+ # }
659
+ # # => nil when the API returns the documented no-data envelope
660
+ def send_token(document_id, recipient: nil, channel: nil, email: nil)
661
+ doc_id = require_id(document_id, 'Document ID')
662
+ if email.nil? && recipient.nil? && channel.nil?
663
+ payload = nil
664
+ elsif !email.nil?
665
+ if recipient || channel
666
+ raise ValidationError.new('Use either email or recipient/channel, not both')
667
+ end
668
+
669
+ payload = { email: require_present(email, 'Email') }
670
+ else
671
+ require_present(recipient, 'Recipient')
672
+ delivery_channel = require_present(channel, 'Channel').to_s
673
+ unless %w[email whatsapp].include?(delivery_channel)
674
+ raise ValidationError.new('Channel must be email or whatsapp')
675
+ end
676
+
677
+ payload = { recipient: recipient, channel: delivery_channel }
678
+ end
679
+
680
+ call('Failed to send signer token') do
681
+ http_put("public/documents/#{doc_id}/send-token", payload && body_params(payload))
682
+ end
683
+ end
684
+
685
+ # List tags attached to a document.
686
+ #
687
+ # @param document_id [String]
688
+ # @param account_id_override [String, nil]
689
+ # @return [Array<Hash>] the document's tag objects
690
+ # @see GET /accounts/{account_id}/documents/{document_id}/tags
691
+ # @example List the tags attached to a document
692
+ # # Request: GET /accounts/{account_id}/documents/{document_id}/tags
693
+ # client.documents.list_tags('1032009d72b364f377ff270405cc')
694
+ #
695
+ # # Response (unwrapped data payload):
696
+ # [
697
+ # {
698
+ # 'id' => '1032009e69e366ca5adc879ef26c',
699
+ # 'name' => 'audit-e2e-renamed',
700
+ # 'color' => 'ff8800',
701
+ # 'created_at' => '2026-06-05T21:21:19Z',
702
+ # 'updated_at' => '2026-06-05T21:21:19Z'
703
+ # }
704
+ # ]
705
+ def list_tags(document_id, account_id_override = nil)
706
+ acc_id = account_id(account_id_override)
707
+ doc_id = require_id(document_id, 'Document ID')
708
+
709
+ call('Failed to list document tags') do
710
+ http_get("accounts/#{acc_id}/documents/#{doc_id}/tags")
711
+ end
712
+ end
713
+
714
+ # Replace the document's full tag set. Passing an empty array detaches
715
+ # all tags from the document.
716
+ #
717
+ # @param document_id [String]
718
+ # @param tags [Array<String>] tag IDs per OpenAPI; the deployed sandbox also accepts existing names
719
+ # @param account_id_override [String, nil]
720
+ # @return [Array<Hash>] the document's full tag set after replacement (empty Array when detaching all)
721
+ # @see PUT /accounts/{account_id}/documents/{document_id}/tags
722
+ # @example Replace the tag set with a single tag
723
+ # # Request: PUT /accounts/{account_id}/documents/{document_id}/tags
724
+ # # Body: { "tags": ["ab12c09f3e709a8a1c82d69b145"] }
725
+ # client.documents.replace_tags('1032009d72b364f377ff270405cc', ['ab12c09f3e709a8a1c82d69b145'])
726
+ #
727
+ # # Response (unwrapped data payload):
728
+ # [
729
+ # {
730
+ # 'id' => 'ab12c09f3e709a8a1c82d69b145',
731
+ # 'name' => 'Contracts',
732
+ # 'color' => nil,
733
+ # 'created_at' => '2026-05-14T12:00:00Z',
734
+ # 'updated_at' => '2026-05-14T12:00:00Z'
735
+ # }
736
+ # ]
737
+ # # Passing [] detaches all tags and returns [].
738
+ def replace_tags(document_id, tags, account_id_override = nil)
739
+ acc_id = account_id(account_id_override)
740
+ doc_id = require_id(document_id, 'Document ID')
741
+
742
+ call('Failed to replace document tags') do
743
+ http_put("accounts/#{acc_id}/documents/#{doc_id}/tags",
744
+ body_params(tags: tag_names(tags, allow_empty: true)))
745
+ end
746
+ end
747
+
748
+ # Attach additional tags to a document without removing existing tags.
749
+ #
750
+ # @param document_id [String]
751
+ # @param tags [Array<String>] tag IDs per OpenAPI; the deployed sandbox also accepts existing names
752
+ # @param account_id_override [String, nil]
753
+ # @return [Array<Hash>] the document's full tag set after the append
754
+ # @see POST /accounts/{account_id}/documents/{document_id}/tags
755
+ # @example Attach a tag without removing existing ones
756
+ # # Request: POST /accounts/{account_id}/documents/{document_id}/tags
757
+ # # Body: { "tags": ["1032009e69e366ca5adc879ef26c"] }
758
+ # client.documents.append_tags('1032009d72b364f377ff270405cc', ['1032009e69e366ca5adc879ef26c'])
759
+ #
760
+ # # Response (unwrapped data payload):
761
+ # [
762
+ # {
763
+ # 'id' => '1032009e69e366ca5adc879ef26c',
764
+ # 'name' => 'audit-e2e-renamed',
765
+ # 'color' => 'ff8800',
766
+ # 'created_at' => '2026-06-05T21:21:19Z',
767
+ # 'updated_at' => '2026-06-05T21:21:19Z'
768
+ # }
769
+ # ]
770
+ def append_tags(document_id, tags, account_id_override = nil)
771
+ acc_id = account_id(account_id_override)
772
+ doc_id = require_id(document_id, 'Document ID')
773
+
774
+ call('Failed to append document tags') do
775
+ http_post("accounts/#{acc_id}/documents/#{doc_id}/tags",
776
+ body_params(tags: tag_names(tags)))
777
+ end
778
+ end
779
+
780
+ # Detach a single tag from a document. The tag itself is not deleted.
781
+ #
782
+ # @param document_id [String]
783
+ # @param tag_id [String]
784
+ # @param account_id_override [String, nil]
785
+ # @return [Hash] `{ 'detached' => true }`
786
+ # @see DELETE /accounts/{account_id}/documents/{document_id}/tags/{tag_id}
787
+ # @example Detach a single tag from a document
788
+ # # Request: DELETE /accounts/{account_id}/documents/{document_id}/tags/{tag_id}
789
+ # client.documents.detach_tag('1032009d72b364f377ff270405cc', 'fa8c09f3e709a8a1c82d69b1454')
790
+ #
791
+ # # Response (unwrapped data payload):
792
+ # { 'detached' => true }
793
+ # # Detaching a tag that was not attached is a no-op (still returns { 'detached' => true }).
794
+ def detach_tag(document_id, tag_id, account_id_override = nil)
795
+ acc_id = account_id(account_id_override)
796
+ doc_id = require_id(document_id, 'Document ID')
797
+ tid = require_id(tag_id, 'Tag ID')
798
+
799
+ call('Failed to detach document tag') do
800
+ http_delete("accounts/#{acc_id}/documents/#{doc_id}/tags/#{tid}")
801
+ end
802
+ end
803
+
804
+ # Convenience: true when the document is `certificated`, or when the
805
+ # embedded assignment summary reports all signers complete.
806
+ #
807
+ # @param document_id [String]
808
+ # @return [Boolean]
809
+ # @example Check whether every signer has completed
810
+ # # Fetches GET /documents/{document_id} and inspects status + assignment.summary.
811
+ # client.documents.fully_signed?('1032009d72b364f377ff270405cc')
812
+ #
813
+ # # Return value (computed locally from the document, not a server payload):
814
+ # # => false (true when status == 'certificated', or every signer in
815
+ # # assignment.summary is completed)
816
+ def fully_signed?(document_id)
817
+ doc = details(document_id)
818
+ return true if doc['status'] == 'certificated'
819
+
820
+ summary = doc.dig('assignment', 'summary')
821
+ if summary && summary['signer_count'].is_a?(Integer)
822
+ summary['signer_count'] > 0 && summary['signer_count'] == summary['completed_count']
823
+ else
824
+ false
825
+ end
826
+ end
827
+
828
+ # Convenience: derive a {signed, total, pending, percentage} progress
829
+ # Hash from the document's assignment summary.
830
+ #
831
+ # @param document_id [String]
832
+ # @return [Hash{Symbol=>Integer,Float}]
833
+ # @example Derive signing progress from the document's assignment summary
834
+ # # Fetches GET /documents/{document_id} and reduces assignment.summary locally.
835
+ # client.documents.signing_progress('1032009d72b364f377ff270405cc')
836
+ #
837
+ # # Return value (computed locally; percentage is signed/total rounded to 2 decimals):
838
+ # { signed: 0, total: 0, pending: 0, percentage: 0.0 }
839
+ def signing_progress(document_id)
840
+ doc = details(document_id)
841
+ summary = doc.dig('assignment', 'summary')
842
+ signers = doc.dig('assignment', 'signers') || []
843
+
844
+ total = (summary && summary['signer_count']) || signers.length
845
+ signed = (summary && summary['completed_count']) || 0
846
+ pending = [total - signed, 0].max
847
+ percentage = total > 0 ? (signed.to_f / total * 10_000).round / 100.0 : 0.0
848
+
849
+ { signed: signed, total: total, pending: pending, percentage: percentage }
850
+ end
851
+
852
+ private
853
+
854
+ def validate_upload!(buffer, file_name)
855
+ if buffer.nil? || buffer.bytesize == 0
856
+ raise ValidationError.new('File buffer is empty', { file_name: file_name })
857
+ end
858
+
859
+ unless file_name.to_s.downcase.end_with?('.pdf')
860
+ raise ValidationError.new('Only PDF files are supported', { file_name: file_name })
861
+ end
862
+
863
+ if buffer.bytesize > MAX_UPLOAD_BYTES
864
+ raise ValidationError.new(
865
+ 'File size exceeds maximum allowed (25MB)',
866
+ { file_size: buffer.bytesize, max_size: MAX_UPLOAD_BYTES }
867
+ )
868
+ end
869
+ end
870
+
871
+ def template_body(signers_or_payload, options = {})
872
+ body =
873
+ if signers_or_payload.is_a?(Hash)
874
+ signers_or_payload.merge(options)
875
+ else
876
+ options.merge(signers: signers_or_payload)
877
+ end
878
+
879
+ unless body[:signers] || body['signers']
880
+ raise ValidationError.new('signers are required')
881
+ end
882
+
883
+ body_params(body)
884
+ end
885
+
886
+ def tag_names(tags, allow_empty: false)
887
+ unless tags.is_a?(Array)
888
+ raise ValidationError.new('Tags must be an Array')
889
+ end
890
+
891
+ if tags.empty? && !allow_empty
892
+ raise ValidationError.new('Tags must be a non-empty Array')
893
+ end
894
+
895
+ tags.each do |tag|
896
+ next unless tag.to_s.strip.empty?
897
+
898
+ raise ValidationError.new('Tag names cannot be empty')
899
+ end
900
+
901
+ tags
902
+ end
903
+
904
+ def artifact_type(artifact_name)
905
+ value = require_id(artifact_name, 'Artifact name').to_s
906
+ return value if ARTIFACT_TYPES.include?(value)
907
+
908
+ raise ValidationError.new('Invalid artifact type', { artifact_name: artifact_name })
909
+ end
910
+ end
911
+ end
912
+ end