assinafy 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,476 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assinafy
4
+ module Resources
5
+ # Assignments — invitations to sign a specific document. Covers virtual
6
+ # (no positioned fields) and collect (positioned fields) methods, cost
7
+ # estimation, signer notification resends, declines, and signing.
8
+ #
9
+ # See https://api.assinafy.com.br/v1/docs#assignment for the full
10
+ # documentation of these endpoints.
11
+ class AssignmentResource < BaseResource
12
+ OPTIONAL_FIELDS = %i[message expires_at copy_receivers].freeze
13
+ METHODS = %w[virtual collect].freeze
14
+ SIGN_ITEM_KEY_MAP = {
15
+ 'item_id' => 'itemId',
16
+ 'field_id' => 'fieldId',
17
+ 'page_id' => 'pageId',
18
+ 'value' => 'value'
19
+ }.freeze
20
+
21
+ class << self
22
+ # Normalise a flexible Ruby-side assignment payload into the body
23
+ # shape the API expects. Accepts:
24
+ #
25
+ # - `signers: ['id1', 'id2']` — bare IDs
26
+ # - `signers: [{ id:, verification_method:, notification_methods:, step: }]`
27
+ # - Legacy `signer_ids:`/`signerIds:` arrays of IDs
28
+ #
29
+ # @note The OpenAPI marks top-level `signers` as required, but the sandbox
30
+ # accepts `collect` payloads that reference signer IDs only in positioned
31
+ # fields. This builder preserves that live-compatible form.
32
+ # @param payload [Hash]
33
+ # @param options [Hash]
34
+ # @option options [Boolean] :allow_signers_without_id allow estimate-cost
35
+ # payloads where method-only signer descriptors carry no id
36
+ # @return [Hash] string-keyed body suitable for {#create} / {#estimate_cost}
37
+ # @raise [Assinafy::ValidationError] on missing required fields
38
+ # @example Bare signer IDs are normalised into { id: } hashes (virtual method)
39
+ # Assinafy::Resources::AssignmentResource.build_payload(signers: %w[s1 s2])
40
+ # # => { "method" => "virtual", "signers" => [{ "id" => "s1" }, { "id" => "s2" }] }
41
+ # @example Rich signer descriptors with sequential signing steps and optional fields
42
+ # Assinafy::Resources::AssignmentResource.build_payload(
43
+ # signers: [{ id: "s1", verification_method: "Email", notification_methods: ["Email"], step: 1 }],
44
+ # message: "Please sign",
45
+ # expires_at: "2026-12-31T23:59:00Z",
46
+ # copy_receivers: ["copy-signer-id"]
47
+ # )
48
+ # # => {
49
+ # # "method" => "virtual",
50
+ # # "signers" => [{ "id" => "s1", "verification_method" => "Email",
51
+ # # "notification_methods" => ["Email"], "step" => 1 }],
52
+ # # "message" => "Please sign", "expires_at" => "2026-12-31T23:59:00Z",
53
+ # # "copy_receivers" => ["copy-signer-id"]
54
+ # # }
55
+ # @example Estimate-cost payload — method-only descriptor with no id (allow flag set)
56
+ # Assinafy::Resources::AssignmentResource.build_payload(
57
+ # { signers: [{ verification_method: "Whatsapp" }] }, { allow_signers_without_id: true }
58
+ # )
59
+ # # => { "method" => "virtual", "signers" => [{ "verification_method" => "Whatsapp" }] }
60
+ # @example Collect method — positioned fields include all required display settings
61
+ # Assinafy::Resources::AssignmentResource.build_payload(
62
+ # method: "collect",
63
+ # entries: [{
64
+ # page_id: "page-id",
65
+ # fields: [{
66
+ # signer_id: "signer-id",
67
+ # field_id: "field-id",
68
+ # display_settings: { left: 100, top: 100, width: 240, height: 48, fontSize: 16 }
69
+ # }]
70
+ # }]
71
+ # )
72
+ # # => { "method" => "collect", "entries" => [{ ... }] }
73
+ def build_payload(payload, options = {})
74
+ p = Utils.clean_params(payload).transform_keys(&:to_sym) if payload.is_a?(Hash)
75
+ raise ValidationError.new('Assignment payload must be a Hash') unless p
76
+
77
+ signers = extract_signer_refs(p)
78
+ entries = p[:entries]
79
+ method = (p[:method] || 'virtual').to_s
80
+
81
+ validate_method!(method, signers, entries, p)
82
+
83
+ result = { method: method }
84
+ result[:signers] = signers.map { |ref| normalise_signer_ref(ref, options) } unless signers.empty?
85
+ OPTIONAL_FIELDS.each { |key| result[key] = p[key] if p[key] }
86
+ result[:entries] = entries if entries
87
+ Utils.body_params(result)
88
+ end
89
+
90
+ private
91
+
92
+ def validate_method!(method, signers, entries, payload)
93
+ unless METHODS.include?(method)
94
+ raise ValidationError.new("Assignment method must be one of: #{METHODS.join(', ')}")
95
+ end
96
+
97
+ if method == 'virtual' && signers.empty?
98
+ raise ValidationError.new(
99
+ 'At least one signer is required',
100
+ { signers: payload[:signers] || payload[:signer_ids] || payload[:signerIds] }
101
+ )
102
+ end
103
+
104
+ return unless method == 'collect' && (!entries.is_a?(Array) || entries.empty?)
105
+
106
+ raise ValidationError.new('entries are required for collect assignments')
107
+ end
108
+
109
+ def extract_signer_refs(payload)
110
+ return payload[:signers] if payload[:signers].is_a?(Array) && !payload[:signers].empty?
111
+
112
+ legacy = payload[:signer_ids] || payload[:signerIds]
113
+ legacy.is_a?(Array) ? legacy : []
114
+ end
115
+
116
+ def normalise_signer_ref(ref, options)
117
+ return string_signer_ref(ref) if ref.is_a?(String)
118
+ return hash_signer_ref(ref, options) if ref.is_a?(Hash)
119
+
120
+ raise ValidationError.new('Invalid signer reference', { ref: ref })
121
+ end
122
+
123
+ def string_signer_ref(ref)
124
+ raise ValidationError.new('Signer ID cannot be empty') if ref.empty?
125
+
126
+ { id: ref }
127
+ end
128
+
129
+ def hash_signer_ref(ref, options)
130
+ r = ref.transform_keys(&:to_sym)
131
+ id = r[:id] || r[:signer_id]
132
+
133
+ normalised = {}
134
+ normalised[:id] = id if id
135
+ normalised[:verification_method] = r[:verification_method] if r[:verification_method]
136
+ normalised[:notification_methods] = r[:notification_methods] if r[:notification_methods]
137
+ normalised[:step] = r[:step] unless r[:step].nil?
138
+
139
+ return normalised if id.is_a?(String) && !id.empty?
140
+ return normalised.tap { |h| h.delete(:id) } if options[:allow_signers_without_id]
141
+
142
+ raise ValidationError.new('Invalid signer reference', { ref: ref })
143
+ end
144
+ end
145
+
146
+ # List assignments for an account. The API requires an account context,
147
+ # supplied as the `accountId` query parameter — note the camelCase, which
148
+ # is unusual for this otherwise snake_case API (verified live).
149
+ #
150
+ # @param params [Hash] documented `page` and `per_page` query parameters
151
+ # @param account_id_override [String, nil]
152
+ # @return [Hash{Symbol=>Array,Hash}] `{ data: [assignment, ...], meta: {..} | nil }`
153
+ # @see GET /assignments
154
+ # @example List assignments for the account
155
+ # # Request: GET /assignments?accountId={account_id}
156
+ # client.assignments.list
157
+ #
158
+ # # Response (unwrapped data payload):
159
+ # {
160
+ # data: [
161
+ # {
162
+ # 'id' => 'assignment-id',
163
+ # 'sender_email' => 'sender@example.com',
164
+ # 'method' => 'virtual',
165
+ # 'expires_at' => nil,
166
+ # 'message' => 'Please sign this contract',
167
+ # 'signers' => [
168
+ # { 'id' => 'signer-id', 'full_name' => 'Example Signer',
169
+ # 'email' => 'signer@example.com', 'completed' => false, 'step' => 1 }
170
+ # ]
171
+ # # ... (see docs for the full assignment shape)
172
+ # }
173
+ # ],
174
+ # meta: nil
175
+ # }
176
+ def list(params = {}, account_id_override = nil)
177
+ acc_id = account_id(account_id_override)
178
+
179
+ call_list('Failed to list assignments') do
180
+ http_get('assignments', params.merge(accountId: acc_id))
181
+ end
182
+ end
183
+
184
+ # Create an assignment for a document. See {.build_payload} for the
185
+ # accepted shapes, including the sandbox-compatible `collect` form without
186
+ # a top-level `signers` array.
187
+ #
188
+ # @param document_id [String]
189
+ # @param payload [Hash]
190
+ # @return [Hash] the assignment object (resource, id, method, expires_at, message, signers[],
191
+ # items[], summary{signer_count, completed_count, signers[]}, signing_urls[], copy_receivers[])
192
+ # @see POST /documents/{documentId}/assignments
193
+ # @example Create a virtual assignment for one signer
194
+ # resource.create("document-id", signers: %w[signer-id], message: "Please sign")
195
+ # # Request body the SDK sends:
196
+ # # { "method" => "virtual", "signers" => [{ "id" => "signer-id" }],
197
+ # # "message" => "Please sign" }
198
+ # # => {
199
+ # # "resource" => "assignment", "id" => "assignment-id",
200
+ # # "sender_email" => "sender@example.com", "method" => "virtual",
201
+ # # "expires_at" => nil, "message" => "Please sign",
202
+ # # "signers" => [{ "id" => "signer-id", "full_name" => "Example Signer",
203
+ # # "email" => "signer@example.com", "whatsapp_phone_number" => nil,
204
+ # # "has_accepted_terms" => false, "completed" => false, "notification_history" => [],
205
+ # # "verification_method" => "Email", "notification_methods" => ["Email"],
206
+ # # "step" => 1, "notified" => true }],
207
+ # # "copy_receivers" => [],
208
+ # # "items" => [{ "id" => "assignment-item-id", "page" => nil,
209
+ # # "signer" => { "id" => "signer-id", ... },
210
+ # # "field" => { "id" => "field-id", "name" => "Virtual",
211
+ # # "type" => "virtual", "is_pre_defined" => true, ... },
212
+ # # "display_settings" => [], "value" => nil, "completed" => false }],
213
+ # # "summary" => { "signer_count" => 1, "completed_count" => 0, "signers" => [{ ... }] },
214
+ # # "signing_urls" => [{ "signer_id" => "signer-id",
215
+ # # "url" => "https://app-sandbox.assinafy.com.br/sign/document-id?email=signer%40example.com" }]
216
+ # # } # ... (see docs for full shape)
217
+ def create(document_id, payload)
218
+ doc_id = require_id(document_id, 'Document ID')
219
+ body = self.class.build_payload(payload)
220
+
221
+ @logger.info("Creating assignment for document #{doc_id}")
222
+
223
+ call('Failed to create assignment') do
224
+ http_post("documents/#{doc_id}/assignments", body)
225
+ end
226
+ end
227
+
228
+ # Estimate the credit cost of a potential assignment, without creating it.
229
+ # Accepts the same payload as {#create}, but signer descriptors may omit
230
+ # `id`. An empty descriptor (`{}`) defaults both methods to `Email`.
231
+ #
232
+ # @param document_id [String]
233
+ # @param payload [Hash]
234
+ # @return [Hash] cost breakdown (documents, credits, needs_extra_document, extra_document_cost,
235
+ # total_credits, breakdown[], document_balance, credit_balance, has_sufficient_resources,
236
+ # blocking_reason, message)
237
+ # @see POST /documents/{documentId}/assignments/estimate-cost
238
+ # @example Estimate cost of inviting a WhatsApp signer (no id needed)
239
+ # resource.estimate_cost("document-id",
240
+ # signers: [{ verification_method: "Whatsapp" }])
241
+ # # Request body the SDK sends:
242
+ # # { "method" => "virtual", "signers" => [{ "verification_method" => "Whatsapp" }] }
243
+ # # => {
244
+ # # "documents" => 1, "credits" => 0, "needs_extra_document" => false,
245
+ # # "extra_document_cost" => 0, "total_credits" => 0, "breakdown" => [],
246
+ # # "document_balance" => 62, "credit_balance" => 0,
247
+ # # "has_sufficient_resources" => true, "blocking_reason" => nil, "message" => nil
248
+ # # }
249
+ def estimate_cost(document_id, payload)
250
+ doc_id = require_id(document_id, 'Document ID')
251
+ body = self.class.build_payload(payload, allow_signers_without_id: true)
252
+
253
+ call('Failed to estimate assignment cost') do
254
+ http_post("documents/#{doc_id}/assignments/estimate-cost", body)
255
+ end
256
+ end
257
+
258
+ # Update the expiration timestamp of an existing assignment. The
259
+ # `expires_at` body field is required by the API and accepts an explicit
260
+ # `nil` (serialized as JSON `null`) to mean "no expiration". The value is
261
+ # therefore sent verbatim rather than through {Utils.body_params}, which
262
+ # would drop the nil.
263
+ #
264
+ # @param document_id [String]
265
+ # @param assignment_id [String]
266
+ # @param expires_at [String, nil] ISO 8601 timestamp, or nil for no expiry
267
+ # @return [Hash] the updated assignment object (same shape as {#create}; expires_at reflects
268
+ # the new value — nil when cleared)
269
+ # @see PUT /documents/{documentId}/assignments/{assignmentId}/reset-expiration
270
+ # @example Set a new expiration timestamp
271
+ # resource.reset_expiration("document-id", "assignment-id",
272
+ # "2026-12-31T23:59:00Z")
273
+ # # Request body the SDK sends: { "expires_at" => "2026-12-31T23:59:00Z" }
274
+ # # => { "resource" => "assignment", "id" => "assignment-id",
275
+ # # "method" => "virtual", "expires_at" => "2026-12-31T23:59:00Z",
276
+ # # "signers" => [{ ... }], "items" => [{ ... }], "summary" => { ... },
277
+ # # "signing_urls" => [{ ... }], "copy_receivers" => [] } # ... (see docs for full shape)
278
+ # @example Clear the expiration (nil is sent verbatim as JSON null)
279
+ # resource.reset_expiration("document-id", "assignment-id", nil)
280
+ # # Request body the SDK sends: { "expires_at" => nil }
281
+ # # => { "resource" => "assignment", "id" => "assignment-id",
282
+ # # "method" => "virtual", "expires_at" => nil, ... } # ... (see docs for full shape)
283
+ def reset_expiration(document_id, assignment_id, expires_at)
284
+ doc_id = require_id(document_id, 'Document ID')
285
+ asg_id = require_id(assignment_id, 'Assignment ID')
286
+
287
+ call('Failed to update assignment expiration') do
288
+ http_put("documents/#{doc_id}/assignments/#{asg_id}/reset-expiration",
289
+ { 'expires_at' => expires_at })
290
+ end
291
+ end
292
+
293
+ # Resend the assignment notification (email/WhatsApp) to a signer.
294
+ # May charge credits — use {#estimate_resend_cost} to preview.
295
+ #
296
+ # @param document_id [String]
297
+ # @param assignment_id [String]
298
+ # @param signer_id [String]
299
+ # @return [Hash] delivery confirmation (is_sent, document_id, signer_id)
300
+ # @see PUT /documents/{documentId}/assignments/{assignmentId}/signers/{signerId}/resend
301
+ # @example Resend the signing notification to a signer
302
+ # resource.resend_notification("document-id", "assignment-id", "signer-id")
303
+ # # (no request body)
304
+ # # => { "is_sent" => true, "document_id" => "document-id", "signer_id" => "signer-id" }
305
+ def resend_notification(document_id, assignment_id, signer_id)
306
+ doc_id = require_id(document_id, 'Document ID')
307
+ asg_id = require_id(assignment_id, 'Assignment ID')
308
+ sid = require_id(signer_id, 'Signer ID')
309
+
310
+ call('Failed to resend signer notification') do
311
+ http_put("documents/#{doc_id}/assignments/#{asg_id}/signers/#{sid}/resend")
312
+ end
313
+ end
314
+
315
+ # Estimate the credit cost of resending the notification to a signer.
316
+ #
317
+ # @param document_id [String]
318
+ # @param assignment_id [String]
319
+ # @param signer_id [String]
320
+ # @return [Hash] cost breakdown (documents, credits, needs_extra_document, extra_document_cost,
321
+ # total_credits, breakdown[], document_balance, credit_balance, has_sufficient_resources,
322
+ # blocking_reason, message)
323
+ # @see POST /documents/{documentId}/assignments/{assignmentId}/signers/{signerId}/estimate-resend-cost
324
+ # @example Preview the cost of resending to a WhatsApp signer
325
+ # resource.estimate_resend_cost("document-id", "assignment-id", "signer-id")
326
+ # # (no request body)
327
+ # # => {
328
+ # # "documents" => 1, "credits" => 0.45, "needs_extra_document" => false,
329
+ # # "extra_document_cost" => 0, "total_credits" => 0.45,
330
+ # # "breakdown" => [{ "code" => "NotificationWhatsapp",
331
+ # # "name" => "Whatsapp Notification", "cost" => 0.45,
332
+ # # "quantity" => 1, "unit_cost" => 0.45 }],
333
+ # # "document_balance" => 10, "credit_balance" => 100,
334
+ # # "has_sufficient_resources" => true, "blocking_reason" => nil, "message" => nil
335
+ # # }
336
+ def estimate_resend_cost(document_id, assignment_id, signer_id)
337
+ doc_id = require_id(document_id, 'Document ID')
338
+ asg_id = require_id(assignment_id, 'Assignment ID')
339
+ sid = require_id(signer_id, 'Signer ID')
340
+
341
+ call('Failed to estimate resend cost') do
342
+ http_post("documents/#{doc_id}/assignments/#{asg_id}/signers/#{sid}/estimate-resend-cost")
343
+ end
344
+ end
345
+
346
+ # Fetch the document a signer is being asked to sign (signer-access-code auth).
347
+ #
348
+ # @param signer_access_code [String]
349
+ # @param has_accepted_terms [Boolean, nil]
350
+ # @return [Hash] the document (id, account_id, name, status, artifacts, signing_url, ...) with an
351
+ # embedded current_signer and assignment (items filtered to the current signer); no pages array
352
+ # @see GET /sign
353
+ # @example Resolve the document a signer was invited to sign
354
+ # resource.signer_document(signer_access_code: "signer-access-code")
355
+ # # (no request body — signer-access-code is a query param)
356
+ # # => {
357
+ # # "id" => "document-id", "account_id" => "account-id", "name" => "my_document.pdf",
358
+ # # "status" => "metadata_ready",
359
+ # # "artifacts" => { "original" => "https://.../download/original",
360
+ # # "thumbnail" => "https://.../thumbnail" },
361
+ # # "is_closed" => false, "signing_url" => "%ui_base_url%/sign/doc1",
362
+ # # "decline_reason" => nil, "declined_by" => nil,
363
+ # # "current_signer" => { "id" => "signer-id", "full_name" => "Signer Name",
364
+ # # "email" => "signer@example.com", "has_accepted_terms" => false,
365
+ # # "verification_method" => "Email", "notification_methods" => ["Email"] },
366
+ # # "assignment" => { "id" => "1", "method" => "virtual", "expires_at" => nil,
367
+ # # "items" => [{ "id" => "assignment-item-id", "field" => { "type" => "virtual" }, ... }] }
368
+ # # } # ... (see docs for full shape)
369
+ def signer_document(signer_access_code:, has_accepted_terms: nil)
370
+ call('Failed to fetch signer assignment document') do
371
+ http_get('sign', signer_access_code: signer_access_code,
372
+ has_accepted_terms: has_accepted_terms)
373
+ end
374
+ end
375
+
376
+ # Submit signatures for an assignment as a signer.
377
+ #
378
+ # The API uses camelCase for this body. Callers may pass snake_case
379
+ # (`item_id`, `field_id`, `page_id`, `value`) — this method maps them
380
+ # to the API's `itemId`, `fieldId`, `pageId`, `value`.
381
+ #
382
+ # @param document_id [String]
383
+ # @param assignment_id [String]
384
+ # @param items [Array<Hash>]
385
+ # @param signer_access_code [String]
386
+ # @return [Hash] empty Hash `{}` on success per the API reference. Signing
387
+ # requires an emailed OTP, so this exact shape is not independently
388
+ # verifiable with a workspace API key.
389
+ # @see POST /documents/{documentId}/assignments/{assignmentId}
390
+ # @example Sign with snake_case keys — mapped to camelCase itemId/fieldId/pageId
391
+ # resource.sign("document-id", "assignment-id",
392
+ # [{ item_id: "assignment-item-id", field_id: "field-id",
393
+ # page_id: "page-id", value: "Signed by Example Signer" }],
394
+ # signer_access_code: "signer-access-code")
395
+ # # Request body the SDK sends (snake_case keys mapped to camelCase):
396
+ # # [{ "itemId" => "assignment-item-id", "fieldId" => "field-id",
397
+ # # "pageId" => "page-id", "value" => "Signed by Example Signer" }]
398
+ # # => {} # per the API reference (unverified via workspace key)
399
+ def sign(document_id, assignment_id, items, signer_access_code:)
400
+ doc_id = require_id(document_id, 'Document ID')
401
+ asg_id = require_id(assignment_id, 'Assignment ID')
402
+ body = require_array(items, 'Assignment items').map { |item| normalise_sign_item(item) }
403
+
404
+ call('Failed to sign assignment') do
405
+ http_post("documents/#{doc_id}/assignments/#{asg_id}", body,
406
+ signer_access_code: signer_access_code)
407
+ end
408
+ end
409
+
410
+ # Decline an assignment as a signer.
411
+ #
412
+ # @param document_id [String]
413
+ # @param assignment_id [String]
414
+ # @param decline_reason [String]
415
+ # @param signer_access_code [String]
416
+ # @return [Array] empty array on success (the API returns no payload)
417
+ # @see PUT /documents/{documentId}/assignments/{assignmentId}/reject
418
+ # @example Decline an assignment as the signer
419
+ # resource.decline("document-id", "assignment-id",
420
+ # decline_reason: "I do not agree with clause 2.",
421
+ # signer_access_code: "signer-access-code")
422
+ # # Request body the SDK sends: { "decline_reason" => "I do not agree with clause 2." }
423
+ # # => []
424
+ def decline(document_id, assignment_id, decline_reason:, signer_access_code:)
425
+ doc_id = require_id(document_id, 'Document ID')
426
+ asg_id = require_id(assignment_id, 'Assignment ID')
427
+ reason = require_present(decline_reason, 'Decline reason')
428
+
429
+ call('Failed to decline assignment') do
430
+ http_put("documents/#{doc_id}/assignments/#{asg_id}/reject",
431
+ body_params(decline_reason: reason),
432
+ signer_access_code: signer_access_code)
433
+ end
434
+ end
435
+
436
+ # List the WhatsApp notifications that were sent for an assignment,
437
+ # including the rendered template text.
438
+ #
439
+ # @param document_id [String]
440
+ # @param assignment_id [String]
441
+ # @return [Array<Hash>] notification objects (sent_at, header, body, buttons[]{text}, phone_number,
442
+ # signer_id); empty array when no WhatsApp notifications were sent
443
+ # @see GET /documents/{documentId}/assignments/{assignmentId}/whatsapp-notifications
444
+ # @example List WhatsApp notifications sent for an assignment
445
+ # resource.whatsapp_notifications("document-id", "assignment-id")
446
+ # # (no request body)
447
+ # # => [
448
+ # # { "sent_at" => 1710000000,
449
+ # # "header" => "Documento para assinatura: Contrato de Servico",
450
+ # # "body" => "Oi, Maria.\n\nJoao Silva enviou um documento...",
451
+ # # "buttons" => [{ "text" => "Abrir documento" }],
452
+ # # "phone_number" => "+15555550100", "signer_id" => "signer-id" }
453
+ # # ]
454
+ # # => [] # when no WhatsApp notifications were sent (e.g. email-only assignment)
455
+ def whatsapp_notifications(document_id, assignment_id)
456
+ doc_id = require_id(document_id, 'Document ID')
457
+ asg_id = require_id(assignment_id, 'Assignment ID')
458
+
459
+ call('Failed to list WhatsApp notifications') do
460
+ http_get("documents/#{doc_id}/assignments/#{asg_id}/whatsapp-notifications")
461
+ end
462
+ end
463
+
464
+ private
465
+
466
+ def normalise_sign_item(item)
467
+ return item unless item.is_a?(Hash)
468
+
469
+ item.each_with_object({}) do |(key, value), result|
470
+ raw = key.to_s
471
+ result[SIGN_ITEM_KEY_MAP.fetch(raw, raw)] = value
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end