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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.en.md +27 -4
- data/README.md +100 -1047
- data/docs/API_REFERENCE.md +154 -18
- data/lib/assinafy/client.rb +4 -0
- data/lib/assinafy/resources/account_resource.rb +2 -1
- data/lib/assinafy/resources/assignment_resource.rb +10 -11
- data/lib/assinafy/resources/base_resource.rb +3 -0
- data/lib/assinafy/resources/contract_resource.rb +450 -0
- data/lib/assinafy/resources/document_resource.rb +3 -0
- data/lib/assinafy/resources/field_resource.rb +2 -0
- data/lib/assinafy/resources/signer_resource.rb +3 -0
- data/lib/assinafy/resources/tag_resource.rb +5 -1
- data/lib/assinafy/resources/user_resource.rb +19 -7
- data/lib/assinafy/resources/webhook_resource.rb +35 -5
- data/lib/assinafy/version.rb +1 -1
- data/lib/assinafy.rb +1 -0
- data/sig/assinafy.rbs +18 -0
- metadata +4 -3
|
@@ -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-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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')
|