assinafy 2.0.1 → 2.1.1

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,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ module Resources
5
+ # Contract terms tracked on workspace documents, including amendments,
6
+ # renewals, terminations, and lifecycle queries.
7
+ class ContractResource < BaseResource
8
+ STATUSES = %w[draft active expired terminated].freeze
9
+ RELATION_TYPES = %w[amendment renewal termination].freeze
10
+ SORTS = %w[end_date -end_date updated_at -updated_at].freeze
11
+ LIST_FIELDS = %w[
12
+ status counterparty search end_date_from end_date_to ending_within_days
13
+ external_system external_id sort expand page per-page
14
+ ].freeze
15
+ UPDATE_FIELDS = %w[
16
+ status counterparty_name counterparty_taxpayer_id value currency start_date end_date
17
+ auto_renew renewal_term_months notice_period_days owner_id metadata external_references
18
+ parent_document_id relation_type updated_at
19
+ ].freeze
20
+ RENEW_FIELDS = %w[end_date value term updated_at].freeze
21
+ TERMINATE_FIELDS = %w[reason updated_at].freeze
22
+
23
+ # List tracked contracts in a workspace, most recently updated first.
24
+ #
25
+ # @param params [Hash] optional filters: `status`, `counterparty`, `search`,
26
+ # `end_date_from`, `end_date_to`, `ending_within_days`, `external_system`,
27
+ # `external_id`, `sort`, `expand`, `page`, and `per_page`
28
+ # @param account_id_override [String, nil]
29
+ # @return [Hash{Symbol=>Array,Hash}] `{ data: [...], meta: { ... } }`
30
+ # @raise [Assinafy::ApiError] on an unsuccessful API response
31
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
32
+ # @raise [Assinafy::ValidationError] on invalid filters or account ID
33
+ # @see GET /accounts/{accountId}/contracts
34
+ # @example List active contracts ending in the next 30 days
35
+ # client.contracts.list(status: ['active'], ending_within_days: 30, expand: 'document')
36
+ # # Request: GET /accounts/{accountId}/contracts?status=active&ending_within_days=30&expand=document
37
+ # # => {
38
+ # # data: [{
39
+ # # 'resource' => 'document_contract', 'document_id' => 'document-id',
40
+ # # 'status' => 'active', 'counterparty_name' => 'Example Ltda',
41
+ # # 'counterparty_taxpayer_id' => '11222333000181', 'value' => 120000.50,
42
+ # # 'currency' => 'BRL', 'start_date' => '2026-11-01', 'end_date' => '2027-10-31',
43
+ # # 'auto_renew' => true, 'renewal_term_months' => 12, 'notice_period_days' => 30,
44
+ # # 'term' => 1, 'owner_id' => 'user-id', 'metadata' => { 'crm_stage' => 'won' },
45
+ # # 'external_references' => [{ 'system' => 'hubspot', 'id' => '98765' }],
46
+ # # 'document' => { 'id' => 'document-id', 'name' => 'agreement.pdf', 'status' => 'certificated' },
47
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2026-10-23T12:00:00Z'
48
+ # # }],
49
+ # # meta: { current_page: 1, per_page: 20, total: 1, last_page: 1 }
50
+ # # }
51
+ def list(params = {}, account_id_override = nil)
52
+ acc_id = account_id(account_id_override)
53
+ query = contract_list_params(params)
54
+
55
+ call_list('Failed to list contracts') do
56
+ http_get("accounts/#{acc_id}/contracts", query)
57
+ end
58
+ end
59
+
60
+ # Fetch the contract terms or pending/applied amendment linked to a document.
61
+ # Returns `nil` when the document has no tracked contract terms.
62
+ #
63
+ # @param document_id [String]
64
+ # @param account_id_override [String, nil]
65
+ # @return [Hash, nil] a DocumentContract or DocumentContractAmendment
66
+ # @raise [Assinafy::ApiError] on an unsuccessful response other than 404
67
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
68
+ # @raise [Assinafy::ValidationError] on an invalid ID
69
+ # @see GET /accounts/{accountId}/documents/{documentId}/contract
70
+ # @example Read a contract and its amendments
71
+ # client.contracts.get('document-id')
72
+ # # Request: GET /accounts/{accountId}/documents/document-id/contract
73
+ # # => {
74
+ # # 'resource' => 'document_contract', 'document_id' => 'document-id', 'status' => 'active',
75
+ # # 'counterparty_name' => 'Example Ltda', 'counterparty_taxpayer_id' => '11222333000181',
76
+ # # 'value' => 130000, 'currency' => 'BRL', 'start_date' => '2026-11-01',
77
+ # # 'end_date' => '2028-06-30', 'auto_renew' => true, 'renewal_term_months' => 12,
78
+ # # 'notice_period_days' => 30, 'term' => 2, 'owner_id' => 'user-id',
79
+ # # 'metadata' => { 'crm_stage' => 'won' },
80
+ # # 'external_references' => [{ 'system' => 'hubspot', 'id' => '98765' }],
81
+ # # 'amendments' => [{
82
+ # # 'resource' => 'document_contract_amendment', 'document_id' => 'amendment-id',
83
+ # # 'document_name' => 'Amendment 1.pdf', 'parent_document_id' => 'document-id',
84
+ # # 'relation_type' => 'amendment', 'number' => 1, 'status' => 'applied',
85
+ # # 'document_status' => 'certificated', 'apply_error' => nil, 'currency' => 'BRL',
86
+ # # 'terms' => { 'value' => 130000, 'end_date' => '2028-06-30' },
87
+ # # 'changes' => { 'value' => { 'from' => 120000.50, 'to' => 130000 } },
88
+ # # 'applied_at' => '2027-05-12T18:20:00Z',
89
+ # # 'created_at' => '2027-05-10T15:00:00Z', 'updated_at' => '2027-05-12T18:20:00Z'
90
+ # # }],
91
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2027-05-12T18:20:00Z'
92
+ # # }
93
+ def get(document_id, account_id_override = nil)
94
+ call_optional('Failed to fetch contract') do
95
+ http_get(contract_path(document_id, account_id_override))
96
+ end
97
+ end
98
+
99
+ # Create or partially update contract terms, or link a document as an
100
+ # amendment, renewal, or termination instrument. Explicit `nil` values
101
+ # are sent as JSON null because the API uses them to clear terms.
102
+ #
103
+ # @param document_id [String]
104
+ # @param payload [Hash] at least one DocumentContractInput property
105
+ # @param account_id_override [String, nil]
106
+ # @return [Hash] the saved DocumentContract or DocumentContractAmendment
107
+ # @raise [Assinafy::ApiError] including 403 without the plan and 409 on a stale `updated_at`
108
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
109
+ # @raise [Assinafy::ValidationError] on invalid IDs or payload fields
110
+ # @see PUT /accounts/{accountId}/documents/{documentId}/contract
111
+ # @example Set a document's contract terms
112
+ # client.contracts.update(
113
+ # 'document-id', status: 'active', counterparty_name: 'Example Ltda',
114
+ # value: 120000.50, currency: 'BRL', start_date: '2026-11-01', end_date: '2027-10-31',
115
+ # auto_renew: true, renewal_term_months: 12, notice_period_days: 30,
116
+ # metadata: { crm_stage: 'won' },
117
+ # external_references: [{ system: 'hubspot', id: '98765' }]
118
+ # )
119
+ # # Request body:
120
+ # # { "status":"active", "counterparty_name":"Example Ltda", "value":120000.5,
121
+ # # "currency":"BRL", "start_date":"2026-11-01", "end_date":"2027-10-31",
122
+ # # "auto_renew":true, "renewal_term_months":12, "notice_period_days":30,
123
+ # # "metadata":{"crm_stage":"won"},
124
+ # # "external_references":[{"system":"hubspot","id":"98765"}] }
125
+ # # => { 'resource' => 'document_contract', 'document_id' => 'document-id', 'status' => 'active',
126
+ # # 'counterparty_name' => 'Example Ltda', 'counterparty_taxpayer_id' => nil,
127
+ # # 'value' => 120000.50, 'currency' => 'BRL', 'start_date' => '2026-11-01',
128
+ # # 'end_date' => '2027-10-31', 'auto_renew' => true, 'renewal_term_months' => 12,
129
+ # # 'notice_period_days' => 30, 'term' => 1, 'owner_id' => nil,
130
+ # # 'metadata' => { 'crm_stage' => 'won' },
131
+ # # 'external_references' => [{ 'system' => 'hubspot', 'id' => '98765' }],
132
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2026-10-23T12:00:00Z' }
133
+ # @example Link a signed amendment and preserve nulls
134
+ # client.contracts.update(
135
+ # 'amendment-id', parent_document_id: 'document-id', relation_type: 'amendment',
136
+ # value: 130000, counterparty_name: nil
137
+ # )
138
+ # # Request body includes "counterparty_name": null.
139
+ def update(document_id, payload, account_id_override = nil)
140
+ body = contract_payload(payload, UPDATE_FIELDS, 'Contract payload')
141
+ raise ValidationError.new('Contract payload must change at least one field') if body.empty?
142
+
143
+ validate_contract_input!(body)
144
+
145
+ call('Failed to update contract') do
146
+ http_put(contract_path(document_id, account_id_override), body)
147
+ end
148
+ end
149
+
150
+ # Fetch the root contract and every related amendment, renewal, and
151
+ # termination document. Returns `nil` when the document has no family.
152
+ #
153
+ # @param document_id [String]
154
+ # @param account_id_override [String, nil]
155
+ # @return [Hash, nil] root DocumentContract with `amendments`
156
+ # @raise [Assinafy::ApiError] on an unsuccessful response other than 404
157
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
158
+ # @raise [Assinafy::ValidationError] on an invalid ID
159
+ # @see GET /accounts/{accountId}/documents/{documentId}/contract/related
160
+ # @example Read a family from one of its amendments
161
+ # client.contracts.related('amendment-id')
162
+ # # Request: GET /accounts/{accountId}/documents/amendment-id/contract/related
163
+ # # => { 'resource' => 'document_contract', 'document_id' => 'document-id', 'status' => 'active',
164
+ # # 'counterparty_name' => 'Example Ltda', 'counterparty_taxpayer_id' => '11222333000181',
165
+ # # 'value' => 130000, 'currency' => 'BRL', 'start_date' => '2026-11-01',
166
+ # # 'end_date' => '2028-06-30', 'auto_renew' => true, 'renewal_term_months' => 12,
167
+ # # 'notice_period_days' => 30, 'term' => 2, 'owner_id' => 'user-id',
168
+ # # 'metadata' => nil, 'external_references' => [],
169
+ # # 'amendments' => [{ 'document_id' => 'amendment-id', 'relation_type' => 'amendment',
170
+ # # 'number' => 1, 'status' => 'applied', 'terms' => { 'value' => 130000 } }],
171
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2027-05-12T18:20:00Z' }
172
+ def related(document_id, account_id_override = nil)
173
+ call_optional('Failed to fetch related contracts') do
174
+ http_get("#{contract_path(document_id, account_id_override)}/related")
175
+ end
176
+ end
177
+
178
+ # Start the contract's next term. Send the current `term` for an
179
+ # idempotent term-based renewal, or an explicit later `end_date`.
180
+ # Explicit `value: nil` clears the contract value.
181
+ #
182
+ # @param document_id [String]
183
+ # @param payload [Hash] `term` or `end_date`, plus optional `value` and `updated_at`
184
+ # @param account_id_override [String, nil]
185
+ # @return [Hash] the renewed DocumentContract
186
+ # @raise [Assinafy::ApiError] including 403 without the plan and 409 on a stale `updated_at`
187
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
188
+ # @raise [Assinafy::ValidationError] on invalid IDs or payload fields
189
+ # @see POST /accounts/{accountId}/documents/{documentId}/contract/renew
190
+ # @example Renew from the current term
191
+ # client.contracts.renew('document-id', term: 1, value: 126000)
192
+ # # Request body: { "term": 1, "value": 126000 }
193
+ # # => { 'resource' => 'document_contract', 'document_id' => 'document-id', 'status' => 'active',
194
+ # # 'counterparty_name' => 'Example Ltda', 'counterparty_taxpayer_id' => '11222333000181',
195
+ # # 'value' => 126000, 'currency' => 'BRL', 'start_date' => '2026-11-01',
196
+ # # 'end_date' => '2028-10-31', 'auto_renew' => true, 'renewal_term_months' => 12,
197
+ # # 'notice_period_days' => 30, 'term' => 2, 'owner_id' => 'user-id',
198
+ # # 'metadata' => nil, 'external_references' => [],
199
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2027-10-15T09:30:00Z' }
200
+ def renew(document_id, payload, account_id_override = nil)
201
+ body = contract_payload(payload, RENEW_FIELDS, 'Contract renewal payload')
202
+ validate_renewal!(body)
203
+
204
+ call('Failed to renew contract') do
205
+ http_post("#{contract_path(document_id, account_id_override)}/renew", body)
206
+ end
207
+ end
208
+
209
+ # Terminate a contract and stop lifecycle alerts. The optional reason is
210
+ # recorded in the document activity event.
211
+ #
212
+ # @param document_id [String]
213
+ # @param payload [Hash] optional `reason` and optimistic-lock `updated_at`
214
+ # @param account_id_override [String, nil]
215
+ # @return [Hash] the terminated DocumentContract
216
+ # @raise [Assinafy::ApiError] including 403 without the plan and 409 on a stale `updated_at`
217
+ # @raise [Assinafy::NetworkError] on transport or TLS failure
218
+ # @raise [Assinafy::ValidationError] on invalid IDs or payload fields
219
+ # @see POST /accounts/{accountId}/documents/{documentId}/contract/terminate
220
+ # @example Terminate with a reason
221
+ # client.contracts.terminate('document-id', reason: 'Supplier replaced')
222
+ # # Request body: { "reason": "Supplier replaced" }
223
+ # # => { 'resource' => 'document_contract', 'document_id' => 'document-id', 'status' => 'terminated',
224
+ # # 'counterparty_name' => 'Example Ltda', 'counterparty_taxpayer_id' => '11222333000181',
225
+ # # 'value' => 120000.50, 'currency' => 'BRL', 'start_date' => '2026-11-01',
226
+ # # 'end_date' => '2027-10-31', 'auto_renew' => true, 'renewal_term_months' => 12,
227
+ # # 'notice_period_days' => 30, 'term' => 1, 'owner_id' => 'user-id',
228
+ # # 'metadata' => nil, 'external_references' => [],
229
+ # # 'created_at' => '2026-10-23T12:00:00Z', 'updated_at' => '2027-03-02T14:00:00Z' }
230
+ def terminate(document_id, payload = {}, account_id_override = nil)
231
+ body = contract_payload(payload, TERMINATE_FIELDS, 'Contract termination payload')
232
+ validate_optional_string!(body, 'reason', 'Termination reason', max_length: 500)
233
+ validate_optional_string!(body, 'updated_at', 'updated_at')
234
+
235
+ call('Failed to terminate contract') do
236
+ http_post("#{contract_path(document_id, account_id_override)}/terminate", body)
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def contract_path(document_id, account_id_override)
243
+ doc_id = require_id(document_id, 'Document ID')
244
+ "accounts/#{account_id(account_id_override)}/documents/#{doc_id}/contract"
245
+ end
246
+
247
+ def contract_list_params(params)
248
+ query = query_params(require_payload(params, 'Contract filters'))
249
+ reject_unknown_fields!(query, LIST_FIELDS, 'contract filters')
250
+ validate_status_filter!(query)
251
+ %w[counterparty search external_system external_id].each do |field|
252
+ validate_optional_string!(query, field, field)
253
+ end
254
+ %w[end_date_from end_date_to].each { |field| validate_optional_date!(query, field) }
255
+ if query['end_date_from'] && query['end_date_to'] && query['end_date_from'] > query['end_date_to']
256
+ raise ValidationError.new('end_date_from must not be after end_date_to')
257
+ end
258
+
259
+ if query.key?('ending_within_days')
260
+ validate_integer!(query['ending_within_days'], 'ending_within_days', 0, 3650)
261
+ end
262
+ validate_integer!(query['page'], 'page', 1) if query.key?('page')
263
+ validate_integer!(query['per-page'], 'per_page', 1, 100) if query.key?('per-page')
264
+ validate_enum!(query, 'sort', SORTS)
265
+ validate_enum!(query, 'expand', %w[document])
266
+ query
267
+ end
268
+
269
+ def validate_status_filter!(query)
270
+ return unless query.key?('status')
271
+
272
+ statuses = query['status'].is_a?(Array) ? query['status'] : query['status'].to_s.split(',')
273
+ unless !statuses.empty? && statuses.all? { |status| status.is_a?(String) && STATUSES.include?(status) }
274
+ raise ValidationError.new("status must contain only: #{STATUSES.join(', ')}")
275
+ end
276
+
277
+ query['status'] = statuses.join(',')
278
+ end
279
+
280
+ def contract_payload(payload, allowed, name)
281
+ raw = require_payload(payload, name)
282
+ body = body_params(raw)
283
+ raw.each { |key, value| body[key.to_s] = nil if value.nil? }
284
+ reject_unknown_fields!(body, allowed, name.downcase)
285
+ body
286
+ end
287
+
288
+ def validate_contract_input!(body)
289
+ validate_enum!(body, 'status', STATUSES)
290
+ validate_enum!(body, 'relation_type', RELATION_TYPES)
291
+ validate_contract_strings!(body)
292
+ validate_contract_dates!(body)
293
+ validate_contract_numbers!(body)
294
+ validate_contract_collections!(body)
295
+ end
296
+
297
+ def validate_contract_strings!(body)
298
+ validate_optional_string!(body, 'counterparty_name', 'counterparty_name', max_length: 255)
299
+ validate_optional_string!(body, 'counterparty_taxpayer_id', 'counterparty_taxpayer_id')
300
+ validate_optional_string!(body, 'owner_id', 'owner_id')
301
+ validate_optional_timestamp!(body, 'updated_at')
302
+ validate_parent_document_id!(body)
303
+ validate_currency!(body)
304
+ end
305
+
306
+ def validate_contract_dates!(body)
307
+ %w[start_date end_date].each { |field| validate_optional_date!(body, field, minimum_year: 1000) }
308
+ if body['start_date'] && body['end_date'] && body['start_date'] > body['end_date']
309
+ raise ValidationError.new('end_date must not be before start_date')
310
+ end
311
+ end
312
+
313
+ def validate_contract_numbers!(body)
314
+ if body.key?('value') && !body['value'].nil?
315
+ validate_number!(body['value'], 'value', 0, 9_999_999_999_999.99)
316
+ end
317
+ validate_boolean!(body, 'auto_renew')
318
+ validate_integer!(body['renewal_term_months'], 'renewal_term_months', 1, 600) if body['renewal_term_months']
319
+ validate_integer!(body['notice_period_days'], 'notice_period_days', 0, 3650) if body['notice_period_days']
320
+ end
321
+
322
+ def validate_contract_collections!(body)
323
+ validate_metadata!(body['metadata']) if body.key?('metadata') && !body['metadata'].nil?
324
+ if body.key?('external_references') && !body['external_references'].nil?
325
+ validate_external_references!(body['external_references'])
326
+ end
327
+ end
328
+
329
+ def validate_renewal!(body)
330
+ unless body['term'] || body['end_date']
331
+ raise ValidationError.new('Contract renewal requires term or end_date')
332
+ end
333
+
334
+ validate_optional_date!(body, 'end_date')
335
+ validate_integer!(body['term'], 'term', 1) if body.key?('term')
336
+ validate_number!(body['value'], 'value', 0, 9_999_999_999_999.99) if body.key?('value') && !body['value'].nil?
337
+ validate_optional_timestamp!(body, 'updated_at')
338
+ end
339
+
340
+ def validate_parent_document_id!(body)
341
+ return unless body.key?('parent_document_id') && !body['parent_document_id'].nil?
342
+
343
+ require_id(body['parent_document_id'], 'parent_document_id')
344
+ end
345
+
346
+ def validate_currency!(body)
347
+ return unless body.key?('currency') && !body['currency'].nil?
348
+
349
+ currency = require_string(body['currency'], 'currency')
350
+ raise ValidationError.new('currency must be a three-letter code') unless currency.match?(/\A[A-Za-z]{3}\z/)
351
+ end
352
+
353
+ def validate_optional_date!(body, field, minimum_year: nil)
354
+ return unless body.key?(field) && !body[field].nil?
355
+
356
+ value = require_string(body[field], field)
357
+ date = Date.iso8601(value)
358
+ raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/) && (!minimum_year || date.year >= minimum_year)
359
+ rescue ArgumentError
360
+ raise ValidationError.new("#{field} must be YYYY-MM-DD")
361
+ end
362
+
363
+ def validate_optional_string!(body, field, name, max_length: nil)
364
+ return unless body.key?(field) && !body[field].nil?
365
+
366
+ require_string(body[field], name, max_length: max_length)
367
+ end
368
+
369
+ def validate_enum!(body, field, allowed)
370
+ return unless body.key?(field)
371
+
372
+ value = require_string(body[field], field)
373
+ return if allowed.include?(value)
374
+
375
+ raise ValidationError.new("#{field} must be one of: #{allowed.join(', ')}")
376
+ end
377
+
378
+ def validate_boolean!(body, field)
379
+ require_boolean(body[field], field) if body.key?(field) && !body[field].nil?
380
+ end
381
+
382
+ def validate_optional_timestamp!(body, field)
383
+ return unless body.key?(field) && !body[field].nil?
384
+
385
+ value = require_string(body[field], field)
386
+ DateTime.iso8601(value)
387
+ return if value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/)
388
+
389
+ raise ArgumentError
390
+ rescue ArgumentError
391
+ raise ValidationError.new("#{field} must be an ISO 8601 timestamp with a timezone")
392
+ end
393
+
394
+ def validate_integer!(value, name, minimum, maximum = nil)
395
+ valid = value.is_a?(Integer) && value >= minimum && (maximum.nil? || value <= maximum)
396
+ return if valid
397
+
398
+ range = maximum ? "between #{minimum} and #{maximum}" : "at least #{minimum}"
399
+ raise ValidationError.new("#{name} must be an Integer #{range}")
400
+ end
401
+
402
+ def validate_number!(value, name, minimum, maximum = nil)
403
+ number = value.is_a?(Integer) || value.is_a?(Float)
404
+ finite = number && (!value.respond_to?(:finite?) || value.finite?)
405
+ valid = finite && value.round(2) == value && value >= minimum && (maximum.nil? || value <= maximum)
406
+ return if valid
407
+
408
+ raise ValidationError.new("#{name} must be a finite number in the documented range")
409
+ end
410
+
411
+ def validate_metadata!(metadata)
412
+ unless metadata.is_a?(Hash) && metadata.length <= 50
413
+ raise ValidationError.new('metadata must be a Hash with at most 50 entries')
414
+ end
415
+
416
+ metadata.each do |key, value|
417
+ valid_key = key.to_s.match?(/\A[A-Za-z0-9_.-]{1,40}\z/)
418
+ valid_value = value.is_a?(String) && value.length <= 500
419
+ next if valid_key && valid_value
420
+
421
+ raise ValidationError.new('metadata keys or values are invalid')
422
+ end
423
+ end
424
+
425
+ def validate_external_references!(references)
426
+ unless references.is_a?(Array) && references.length <= 20
427
+ raise ValidationError.new('external_references must be an Array with at most 20 entries')
428
+ end
429
+
430
+ references.each do |reference|
431
+ unless reference.is_a?(Hash)
432
+ raise ValidationError.new('Each external reference must be a Hash')
433
+ end
434
+
435
+ ref = reference.transform_keys(&:to_s)
436
+ system = require_string(ref['system'], 'External reference system', max_length: 64)
437
+ require_string(ref['id'], 'External reference ID', max_length: 255)
438
+ unless system.match?(/\A[A-Za-z0-9_.-]+\z/)
439
+ raise ValidationError.new('External reference system contains invalid characters')
440
+ end
441
+ end
442
+ end
443
+
444
+ def reject_unknown_fields!(body, allowed, name)
445
+ unknown = body.keys - allowed
446
+ raise ValidationError.new("Unknown #{name}: #{unknown.join(', ')}") unless unknown.empty?
447
+ end
448
+ end
449
+ end
450
+ end
@@ -40,6 +40,8 @@ module Assinafy
40
40
  # },
41
41
  # 'is_closed' => false,
42
42
  # 'signing_url' => 'https://app-sandbox.assinafy.com.br/sign/document-id',
43
+ # 'verification_code' => nil,
44
+ # 'verification_url' => nil,
43
45
  # 'decline_reason' => nil,
44
46
  # 'declined_by' => nil,
45
47
  # 'tags' => [],
@@ -233,6 +235,7 @@ module Assinafy
233
235
  # 'declined_by' => nil,
234
236
  # 'tags' => [],
235
237
  # 'assignment' => nil,
238
+ # 'has_contract' => false,
236
239
  # 'pages' => [
237
240
  # { 'id' => 'page-id', 'number' => 1, 'height' => 1651,
238
241
  # 'width' => 1275, 'download_url' => 'https://sandbox.assinafy.com.br/v1/documents/.../download' }
@@ -50,6 +50,7 @@ module Assinafy
50
50
  body = with_explicit_regex(raw, body_params(raw))
51
51
  require_string(body['name'], 'Field name')
52
52
  require_string(body['type'], 'Field type')
53
+ require_string(body['regex'], 'Field regex') if body.key?('regex') && !body['regex'].nil?
53
54
  require_boolean(body['is_required'], 'is_required') if body.key?('is_required')
54
55
 
55
56
  call('Failed to create field definition') do
@@ -171,6 +172,7 @@ module Assinafy
171
172
  raw = require_payload(payload)
172
173
  body = with_explicit_regex(raw, body_params(raw))
173
174
  require_string(body['name'], 'Field name') if body.key?('name')
175
+ require_string(body['regex'], 'Field regex') if body.key?('regex') && !body['regex'].nil?
174
176
  require_boolean(body['is_active'], 'is_active') if body.key?('is_active')
175
177
 
176
178
  call('Failed to update field definition') do
@@ -420,6 +420,9 @@ module Assinafy
420
420
  doc_id = require_id(document_id, 'Document ID')
421
421
  body = body_params(require_payload(payload))
422
422
  code = require_signer_access_code(signer_access_code)
423
+ require_string(body['full_name'], 'Full name') if body.key?('full_name')
424
+ Utils.require_email(body['email']) if body.key?('email')
425
+ require_string(body['government_id'], 'Government ID') if body.key?('government_id')
423
426
 
424
427
  call('Failed to confirm signer data') do
425
428
  http_put("documents/#{doc_id}/signers/confirm-data", body,
@@ -52,7 +52,7 @@ module Assinafy
52
52
  #
53
53
  # @param payload [Hash]
54
54
  # @option payload [String] :name required tag display name
55
- # @option payload [String, nil] :color optional 6-character hex color
55
+ # @option payload [String, nil] :color optional 6-digit hex color, with or without `#`
56
56
  # @param account_id_override [String, nil]
57
57
  # @return [Hash] the created tag object
58
58
  # @raise [Assinafy::ValidationError] if `:name` is missing or blank
@@ -168,6 +168,10 @@ module Assinafy
168
168
  body['color'] = nil if explicit_nil_color?(raw)
169
169
 
170
170
  validate_tag_name!(body, require_name: require_name)
171
+ if body.key?('color') && !body['color'].nil?
172
+ color = require_string(body['color'], 'Tag color')
173
+ raise ValidationError.new('Tag color must be a 6-digit hex color') unless color.match?(/\A#?[0-9a-f]{6}\z/i)
174
+ end
171
175
 
172
176
  if !require_name && body.empty?
173
177
  raise ValidationError.new('Provide at least one of name or color to update')
@@ -16,6 +16,10 @@ module Assinafy
16
16
  DocumentProcessingFailed
17
17
  TemplateProcessingFailed
18
18
  SignerWhatsappFailed
19
+ ContractRenewalNotice
20
+ ContractExpiring
21
+ ContractExpired
22
+ ContractRenewed
19
23
  ].freeze
20
24
 
21
25
  # Fetch the authenticated user's profile. The current OpenAPI response is
@@ -82,7 +86,7 @@ module Assinafy
82
86
  # @note Documented in the API reference, but gated on the account's plan:
83
87
  # this route answers 404 where the KPI feature is not enabled.
84
88
  # @param granularity [String, nil] `"monthly"` or `"daily"`
85
- # @param month [String, nil] e.g. `"2026-06"`
89
+ # @param month [String, nil] e.g. `"2026-06"`; required with daily granularity
86
90
  # @return [Array<Hash>] one KPI entry per period
87
91
  # @raise [Assinafy::ApiError] on an unsuccessful API response
88
92
  # @raise [Assinafy::NetworkError] on transport or TLS failure
@@ -117,11 +121,11 @@ module Assinafy
117
121
  end
118
122
  end
119
123
 
120
- # Fetch all owner-facing document email preferences. All nine keys are
124
+ # Fetch all owner-facing document and contract email preferences. All thirteen keys are
121
125
  # returned and default to true. Account and security email is not
122
126
  # configurable through this endpoint.
123
127
  #
124
- # @return [Hash{String=>Boolean}] all nine documented preference codes
128
+ # @return [Hash{String=>Boolean}] all thirteen documented preference codes
125
129
  # @raise [Assinafy::ApiError] on an unsuccessful API response
126
130
  # @raise [Assinafy::NetworkError] on transport or TLS failure
127
131
  # @raise [Assinafy::ValidationError] on invalid required input
@@ -139,7 +143,11 @@ module Assinafy
139
143
  # 'DocumentExpirationReset' => true,
140
144
  # 'DocumentProcessingFailed' => true,
141
145
  # 'TemplateProcessingFailed' => true,
142
- # 'SignerWhatsappFailed' => true
146
+ # 'SignerWhatsappFailed' => true,
147
+ # 'ContractRenewalNotice' => true,
148
+ # 'ContractExpiring' => true,
149
+ # 'ContractExpired' => true,
150
+ # 'ContractRenewed' => true
143
151
  # }
144
152
  def notification_preferences
145
153
  call('Failed to fetch notification preferences') do
@@ -147,10 +155,10 @@ module Assinafy
147
155
  end
148
156
  end
149
157
 
150
- # Merge selected owner-facing document email preferences.
158
+ # Merge selected owner-facing document and contract email preferences.
151
159
  #
152
160
  # Omitted keys keep their current values. The API returns the full
153
- # nine-key map shown by {#notification_preferences}.
161
+ # thirteen-key map shown by {#notification_preferences}.
154
162
  #
155
163
  # @param preferences [Hash{String,Symbol=>Boolean}] non-empty partial map
156
164
  # @return [Hash{String=>Boolean}] the full updated map
@@ -174,7 +182,11 @@ module Assinafy
174
182
  # 'DocumentExpirationReset' => true,
175
183
  # 'DocumentProcessingFailed' => true,
176
184
  # 'TemplateProcessingFailed' => true,
177
- # 'SignerWhatsappFailed' => true
185
+ # 'SignerWhatsappFailed' => true,
186
+ # 'ContractRenewalNotice' => true,
187
+ # 'ContractExpiring' => true,
188
+ # 'ContractExpired' => true,
189
+ # 'ContractRenewed' => true
178
190
  # }
179
191
  def update_notification_preferences(preferences)
180
192
  preferences = require_payload(preferences, 'Notification preferences')