assinafy 2.0.0 → 2.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.en.md +33 -8
- data/README.md +39 -3
- data/docs/API_REFERENCE.md +155 -13
- data/lib/assinafy/client.rb +4 -0
- data/lib/assinafy/resources/account_resource.rb +1 -0
- data/lib/assinafy/resources/assignment_resource.rb +22 -11
- data/lib/assinafy/resources/contract_resource.rb +450 -0
- data/lib/assinafy/resources/document_resource.rb +3 -0
- data/lib/assinafy/resources/user_resource.rb +18 -6
- 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 +19 -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' }
|
|
@@ -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
|
|
@@ -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')
|
|
@@ -28,7 +28,8 @@ module Assinafy
|
|
|
28
28
|
# @option payload [Array<String>] :events event-type IDs (see {#list_event_types})
|
|
29
29
|
# @option payload [Boolean] :is_active default `true` when omitted
|
|
30
30
|
# @param account_id_override [String, nil]
|
|
31
|
-
# @return [Hash] the subscription object: { events:, is_active:, url:, email:,
|
|
31
|
+
# @return [Hash] the subscription object: { events:, is_active:, is_held:, held_since:, url:, email:,
|
|
32
|
+
# updated_at: }
|
|
32
33
|
# @raise [Assinafy::ApiError] on an unsuccessful API response
|
|
33
34
|
# @raise [Assinafy::NetworkError] on transport or TLS failure
|
|
34
35
|
# @raise [Assinafy::ValidationError] on invalid required input
|
|
@@ -51,6 +52,8 @@ module Assinafy
|
|
|
51
52
|
# # {
|
|
52
53
|
# # 'events' => ["document_ready", "document_prepared"],
|
|
53
54
|
# # 'is_active' => true,
|
|
55
|
+
# # 'is_held' => false,
|
|
56
|
+
# # 'held_since' => nil,
|
|
54
57
|
# # 'url' => "https://example.com/webhook",
|
|
55
58
|
# # 'email' => "ops@example.com",
|
|
56
59
|
# # 'updated_at' => "2026-06-05T21:13:24Z"
|
|
@@ -84,6 +87,8 @@ module Assinafy
|
|
|
84
87
|
# # {
|
|
85
88
|
# # 'events' => ["document_ready", "signer_signed_document"],
|
|
86
89
|
# # 'is_active' => false,
|
|
90
|
+
# # 'is_held' => false,
|
|
91
|
+
# # 'held_since' => nil,
|
|
87
92
|
# # 'url' => "https://example.com/sdk-smoke-webhook",
|
|
88
93
|
# # 'email' => "webhook@example.com",
|
|
89
94
|
# # 'updated_at' => "2026-06-05T21:13:24Z"
|
|
@@ -113,6 +118,8 @@ module Assinafy
|
|
|
113
118
|
# # {
|
|
114
119
|
# # 'events' => ["document_ready", "document_prepared"],
|
|
115
120
|
# # 'is_active' => false,
|
|
121
|
+
# # 'is_held' => false,
|
|
122
|
+
# # 'held_since' => nil,
|
|
116
123
|
# # 'url' => "https://example.com/webhook",
|
|
117
124
|
# # 'email' => "ops@example.com",
|
|
118
125
|
# # 'updated_at' => "2026-06-05T21:13:24Z"
|
|
@@ -178,6 +185,10 @@ module Assinafy
|
|
|
178
185
|
# # endpoint: "https://example.com/webhook",
|
|
179
186
|
# # payload: { 'id' => 15431, event: "signature_requested", object: {}, subject: {}, payload: {} },
|
|
180
187
|
# # delivered: false,
|
|
188
|
+
# # webhook_id: "webhook-id-placeholder",
|
|
189
|
+
# # attempt: 2,
|
|
190
|
+
# # kind: "scheduled",
|
|
191
|
+
# # will_retry: false,
|
|
181
192
|
# # http_status: 404,
|
|
182
193
|
# # response_body: "{\"success\":false,...}",
|
|
183
194
|
# # error: "Client error: `POST https://example.com/webhook` resulted in a 404 ...",
|
|
@@ -217,6 +228,10 @@ module Assinafy
|
|
|
217
228
|
# # endpoint: "https://example.com/webhook",
|
|
218
229
|
# # payload: { 'id' => 15431, event: "signature_requested", object: {}, subject: {} },
|
|
219
230
|
# # delivered: true,
|
|
231
|
+
# # webhook_id: "webhook-id-placeholder",
|
|
232
|
+
# # attempt: 1,
|
|
233
|
+
# # kind: "manual",
|
|
234
|
+
# # will_retry: false,
|
|
220
235
|
# # http_status: 200,
|
|
221
236
|
# # response_body: "OK",
|
|
222
237
|
# # error: nil,
|
|
@@ -247,12 +262,15 @@ module Assinafy
|
|
|
247
262
|
# # => unwrapped data payload returned:
|
|
248
263
|
# # [
|
|
249
264
|
# # {
|
|
265
|
+
# # 'resource' => "activity_event_subscription",
|
|
250
266
|
# # 'id' => "webhook-endpoint-id",
|
|
251
267
|
# # 'name' => "ERP",
|
|
252
268
|
# # 'url' => "https://example.com/webhooks/assinafy",
|
|
253
269
|
# # 'email' => "ops@example.com",
|
|
254
270
|
# # 'events' => ["document_ready", "signer_signed_document"],
|
|
255
271
|
# # 'is_active' => true,
|
|
272
|
+
# # 'is_held' => false,
|
|
273
|
+
# # 'held_since' => nil,
|
|
256
274
|
# # 'signing_enabled' => true,
|
|
257
275
|
# # 'created_at' => "2026-10-01T12:00:00Z",
|
|
258
276
|
# # 'updated_at' => "2026-10-01T12:00:00Z"
|
|
@@ -274,7 +292,7 @@ module Assinafy
|
|
|
274
292
|
# especially for OAuth apps, which cannot call {#endpoint_secret}.
|
|
275
293
|
#
|
|
276
294
|
# @param payload [Hash]
|
|
277
|
-
# @option payload [String] :url
|
|
295
|
+
# @option payload [String] :url HTTPS URL that receives the events (required)
|
|
278
296
|
# @option payload [String] :email contact email for delivery-failure notices (required)
|
|
279
297
|
# @option payload [Array<String>] :events event-type IDs (see {#list_event_types}) (required)
|
|
280
298
|
# @option payload [String] :name label to tell endpoints apart
|
|
@@ -304,8 +322,10 @@ module Assinafy
|
|
|
304
322
|
# # "signing_enabled": true
|
|
305
323
|
# # }
|
|
306
324
|
# # => unwrapped data payload returned (same shape as {#list_endpoints} entries):
|
|
307
|
-
# # { '
|
|
308
|
-
# # '
|
|
325
|
+
# # { 'resource' => "activity_event_subscription", 'id' => "webhook-endpoint-id", 'name' => "ERP",
|
|
326
|
+
# # 'url' => "https://example.com/webhooks/assinafy", 'email' => "ops@example.com",
|
|
327
|
+
# # 'events' => [...], 'is_active' => true, 'is_held' => false, 'held_since' => nil,
|
|
328
|
+
# # 'signing_enabled' => true,
|
|
309
329
|
# # 'created_at' => "2026-10-01T12:00:00Z", 'updated_at' => "2026-10-01T12:00:00Z",
|
|
310
330
|
# # 'secret' => 'whsec_secret-placeholder' } # secret appears only on creation with signing enabled
|
|
311
331
|
def create_endpoint(payload, account_id_override = nil)
|
|
@@ -481,7 +501,7 @@ module Assinafy
|
|
|
481
501
|
end
|
|
482
502
|
|
|
483
503
|
def validate_webhook_fields!(body)
|
|
484
|
-
|
|
504
|
+
validate_webhook_url!(body[:url]) if body.key?(:url)
|
|
485
505
|
Utils.require_email(body[:email]) if body.key?(:email)
|
|
486
506
|
require_string(body[:name], 'Webhook name') if body.key?(:name)
|
|
487
507
|
%i[is_active signing_enabled].each { |key| require_boolean(body[key], key.to_s) if body.key?(key) }
|
|
@@ -492,6 +512,16 @@ module Assinafy
|
|
|
492
512
|
|
|
493
513
|
raise ValidationError.new('Webhook events must be non-empty Strings')
|
|
494
514
|
end
|
|
515
|
+
|
|
516
|
+
def validate_webhook_url!(value)
|
|
517
|
+
url = require_string(value, 'Webhook URL')
|
|
518
|
+
uri = URI.parse(url)
|
|
519
|
+
return if uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty?
|
|
520
|
+
|
|
521
|
+
raise ValidationError.new('Webhook URL must be an absolute HTTPS URL')
|
|
522
|
+
rescue URI::InvalidURIError
|
|
523
|
+
raise ValidationError.new('Webhook URL must be an absolute HTTPS URL')
|
|
524
|
+
end
|
|
495
525
|
end
|
|
496
526
|
end
|
|
497
527
|
end
|