kiosk-server 0.5.20

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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Runs one call for an identity inside a {SessionContext}; returns a {Result} or raises {Errors::Base}.
6
+ class Executor
7
+ # The coarse kinds of call shared by the toll, the policy and this dispatcher; not wire paths.
8
+ VERBS = %i[query run pay].freeze
9
+
10
+ # Kinds with an irreversible external effect (a PSP capture) that manage their own transactions.
11
+ SELF_MANAGED_VERBS = %i[pay].freeze
12
+
13
+ # `name` is the query/action path segment; `pay` ignores it.
14
+ def self.call(kind:, args:, identity:, connection:, name: nil)
15
+ new(connection: connection, identity: identity).call(kind: kind, args: args, name: name)
16
+ end
17
+
18
+ attr_reader :connection, :identity
19
+
20
+ def initialize(connection:, identity:)
21
+ raise Errors::Unauthenticated, 'identity required' if identity.nil?
22
+
23
+ @connection = connection
24
+ @identity = identity
25
+ end
26
+
27
+ # `handler` answers in place of the registered one.
28
+ def call(kind:, args:, name: nil, handler: nil)
29
+ verb = kind.to_sym
30
+ raise Errors::BadRequest.new("Unknown verb: #{kind.inspect}", hint: "Valid verbs: #{VERBS.inspect}") unless VERBS.include?(verb)
31
+ # A self-managed verb opens its own SessionContext(s).
32
+ return dispatch(verb, args, name, handler) if SELF_MANAGED_VERBS.include?(verb)
33
+ return audited(name, args) { in_session { dispatch(verb, args, name, handler) } } if verb == :run
34
+
35
+ in_session { dispatch(verb, args, name, handler) }
36
+ end
37
+
38
+ private
39
+
40
+ def in_session(&) = SessionContext.open(connection: connection, identity: identity, &)
41
+
42
+ # Emits one {ActionEvent} per action to the operator's audit sink. Outside the SessionContext,
43
+ # so a rollback cannot erase the record and a sink cannot hold the transaction open.
44
+ def audited(name, args)
45
+ return yield unless AuditSink.configured?
46
+
47
+ invoked_at = Time.current
48
+ begin
49
+ result = yield
50
+ rescue StandardError => e
51
+ emit(name, args, ActionEvent::ERROR, e, invoked_at)
52
+ raise
53
+ end
54
+ emit(name, args, ActionEvent::OK, nil, invoked_at)
55
+ result
56
+ end
57
+
58
+ # Only a registered action counts as invoked; an unknown name is about to answer 404.
59
+ def emit(name, args, status, error, invoked_at)
60
+ return if name.nil? || name.to_s.empty?
61
+ return unless Actions.known.include?(name.to_s)
62
+
63
+ # The args as the handler received them, with symbol keys.
64
+ AuditSink.emit(
65
+ ActionEvent.build(identity: identity, name: name.to_s, args: symbolize(args),
66
+ status: status, error: error, invoked_at: invoked_at)
67
+ )
68
+ end
69
+
70
+ def dispatch(verb, args, name, handler)
71
+ case verb
72
+ when :query then verb_query(args, name, handler)
73
+ when :run then verb_run(args, name, handler)
74
+ when :pay then verb_pay(args)
75
+ end
76
+ end
77
+
78
+ # A handler returns rows or a {Page}; the page facts become the `Link` and `X-Total-Count` headers (§8.2).
79
+ def verb_query(args, name, handler)
80
+ args = symbolize(args)
81
+ raise Errors::BadRequest, 'query name required' if name.nil? || name.to_s.empty?
82
+
83
+ returned = invoke('Query', handler || Queries.fetch(name), name, args)
84
+ validate_response!(Queries, :query, name, rows_result(returned))
85
+ end
86
+
87
+ def rows_result(returned)
88
+ return Result.new(kind: :rows, payload: returned) unless returned.is_a?(Page)
89
+
90
+ Result.new(kind: :rows, payload: returned.rows, next_cursor: returned.next_cursor, total: returned.total)
91
+ end
92
+
93
+ def verb_run(args, name, handler)
94
+ args = symbolize(args)
95
+ raise Errors::BadRequest, 'action name required' if name.nil? || name.to_s.empty?
96
+
97
+ value = invoke('Action', handler || Actions.fetch(name), name, args)
98
+ validate_response!(Actions, :action, name, Result.new(kind: :value, payload: value))
99
+ end
100
+
101
+ # The handler's answer. A Kiosk error passes through; any other error is logged and answers `action_failed`.
102
+ def invoke(label, handler, name, args)
103
+ handler.call(args)
104
+ rescue Errors::Base
105
+ raise
106
+ rescue StandardError => e
107
+ report_handler_failure(label, name, e)
108
+ raise Errors::ActionFailed.new("#{label} #{name.inspect} failed", hint: 'See server logs for the backtrace.')
109
+ end
110
+
111
+ # Checks the answer against the published `output_schema` when `validate_responses` is on.
112
+ def validate_response!(registry, kind, name, result)
113
+ return result unless Kiosk.configuration.validate_responses
114
+
115
+ ResponseValidation.validate_payload!(
116
+ result.to_payload,
117
+ output_schema: registry.describe(name)[:output_schema],
118
+ verb: name.to_s,
119
+ kind: kind
120
+ )
121
+ result
122
+ end
123
+
124
+ # The handler's message goes to the operator's log, never onto the wire.
125
+ def report_handler_failure(kind, name, error)
126
+ FailureLog.report("#{kind} #{name.inspect} raised #{error.class}", error)
127
+ end
128
+
129
+ def symbolize(value)
130
+ case value
131
+ when Hash then value.transform_keys(&:to_sym)
132
+ when nil then {}
133
+ else value
134
+ end
135
+ end
136
+
137
+ # The `pay` verb: settles an AP2 cart. The PSP capture runs outside any transaction,
138
+ # between phases 1 and 3 (§11.6).
139
+ module Pay
140
+ RETRYABLE_HINT = 'the charge did not go through and no money moved. The human may need to ' \
141
+ 'update the payment method (payment_setup). Then sign a new chain and pay again.'
142
+ UNKNOWN_HINT = 'the payment provider did not confirm the charge, so its status is unknown. ' \
143
+ 'Send the SAME chain again, byte for byte: this operator charges one chain at ' \
144
+ 'most once. Do not sign a new chain.'
145
+ CONFLICT_HINT = 'this operator will not charge this chain: an earlier call with it was declined, ' \
146
+ 'or these mandate ids came before with other content. Before you sign a new ' \
147
+ "chain, read the order's paid state through the operator's own query, and sign " \
148
+ 'a new chain only on a clear "not paid".'
149
+
150
+ # The verified mandates, the cart row they were persisted under, and
151
+ # whether an earlier call already persisted them.
152
+ Chain = Struct.new(:intent, :cart, :payment, :cart_row, :repeat)
153
+
154
+ private
155
+
156
+ def verb_pay(args)
157
+ args = symbolize(args)
158
+ # Whether this origin serves payments is answered before any argument is read.
159
+ provider = payment_provider!
160
+ raws = pay_mandates!(args)
161
+ # Refused before phase 1 so the mandate ids are not burned on a charge that cannot succeed.
162
+ raise Errors::PaymentSetupRequired if provider.respond_to?(:setup_required?) && provider.setup_required?(user_id: identity.user_id)
163
+
164
+ chain = persist_chain(raws)
165
+ return chain if chain.is_a?(Result)
166
+
167
+ settle(provider, chain, capture(provider, chain))
168
+ end
169
+
170
+ def payment_provider!
171
+ provider = Kiosk.configuration.payment_provider
172
+ return provider unless provider.nil?
173
+
174
+ raise Errors::ModuleNotServed.new(
175
+ 'this operator does not serve the payment module',
176
+ hint: "`pay` is absent from this origin's capabilities; hand the transaction to your human"
177
+ )
178
+ end
179
+
180
+ # The three signed mandates from the args, by part.
181
+ def pay_mandates!(args)
182
+ # Mandates are agent-signed, so a principal without an agent id cannot pay.
183
+ raise Errors::Forbidden, 'payment requires an agent identity (mandates are agent-signed)' if identity.agent_id.nil?
184
+
185
+ %i[intent cart payment].to_h do |part|
186
+ raw = args[:"#{part}_mandate_jws"]
187
+ raise Errors::BadRequest, "args.#{part}_mandate_jws required" if raw.nil? || raw.to_s.empty?
188
+
189
+ [part, raw]
190
+ end
191
+ end
192
+
193
+ # Phase 1: verify and persist the mandate chain. A unique violation means the chain was presented before.
194
+ def persist_chain(raws)
195
+ chain = Chain.new
196
+ verify_and_persist(chain, raws)
197
+ rescue StandardError => e
198
+ raise unless unique_violation?(e)
199
+
200
+ repeat(chain)
201
+ end
202
+
203
+ def verify_and_persist(chain, raws)
204
+ SessionContext.open(connection: connection, identity: identity) do
205
+ cart = verify_intent_and_cart(chain, raws)
206
+ # Before any persist or capture, so a refusal burns nothing.
207
+ enforce_spending_cap!(cart)
208
+ chain.payment = MandateVerifier.verify_payment(raw_jws: raws[:payment], identity: identity, cart: cart)
209
+ chain.cart_row = persist_mandates(chain)
210
+ end
211
+ chain
212
+ end
213
+
214
+ def verify_intent_and_cart(chain, raws)
215
+ chain.intent = MandateVerifier.verify_intent(raw_jws: raws[:intent], identity: identity)
216
+ chain.cart = MandateVerifier.verify_cart(raw_jws: raws[:cart], identity: identity, intent: chain.intent)
217
+ end
218
+
219
+ # The cart row id.
220
+ def persist_mandates(chain)
221
+ intent_row = persist_intent_mandate(chain.intent)
222
+ cart_row = persist_cart_mandate(chain.cart, intent_row_id: intent_row)
223
+ persist_payment_mandate(cart_row_id: cart_row, payment: chain.payment)
224
+ cart_row
225
+ end
226
+
227
+ # Phase 2: the irreversible capture. A refusal under which no money moved
228
+ # declines the cart, so it stops counting against the spending cap.
229
+ def capture(provider, chain)
230
+ charge(provider, chain)
231
+ rescue Kiosk::PaymentProviders::SetupRequired
232
+ decline(chain)
233
+ raise Errors::PaymentSetupRequired
234
+ rescue Kiosk::PaymentProviders::PaymentFailed => e
235
+ decline(chain) if e.retryable?
236
+ raise Errors::PaymentFailed.new(e.message, hint: e.retryable? ? RETRYABLE_HINT : UNKNOWN_HINT)
237
+ rescue Errors::Forbidden, Errors::BadRequest
238
+ decline(chain)
239
+ raise
240
+ end
241
+
242
+ # A repeated chain resumes its capture under the same idempotency key.
243
+ def charge(provider, chain)
244
+ method = chain.repeat ? :resume : :capture
245
+ provider.public_send(method, chain.cart, payment_method: chain.payment.payment_method)
246
+ end
247
+
248
+ # Phase 3: record the settlement, then tell the provider, so the
249
+ # operator's work after the payment reads the settlement. A concurrent
250
+ # call that settled the same cart first answers with its settlement.
251
+ def settle(provider, chain, settled)
252
+ settlement_id = record_settlement(chain, settled)
253
+ provider.settled(chain.cart) if provider.respond_to?(:settled)
254
+ settlement_result(settlement_id, settled[:psp_reference], settled[:settled_amount_cents], chain.cart.currency)
255
+ rescue StandardError => e
256
+ row = unique_violation?(e) && recorded_chain(chain)
257
+ raise unless row && row['settlement_id']
258
+
259
+ stored_settlement(row)
260
+ end
261
+
262
+ # The settlement row's id.
263
+ def record_settlement(chain, settled)
264
+ SessionContext.open(connection: connection, identity: identity) do
265
+ persist_settlement(cart_row_id: chain.cart_row, cart: chain.cart, settled: settled)
266
+ end
267
+ end
268
+
269
+ def settlement_result(settlement_id, psp_reference, settled_amount_cents, currency)
270
+ Result.new(kind: :value, payload: { settlement_id:, psp_reference:, settled_amount_cents:, currency: })
271
+ end
272
+ end
273
+
274
+ # The mandate and settlement rows. Every value is a bind parameter; only the
275
+ # operator-set schema name is interpolated.
276
+ module Ledger
277
+ # Byte-identical chain, scoped to the acting principal on every mandate row, with its settlement if any.
278
+ CHAIN_SQL = <<~SQL.squish
279
+ SELECT c.id AS cart_row, c.declined_at,
280
+ s.id AS settlement_id, s.psp_reference, s.settled_amount_cents, s.currency
281
+ FROM %<schema>s.cart_mandates c
282
+ JOIN %<schema>s.intent_mandates i ON i.id = c.intent_mandate_id
283
+ JOIN %<schema>s.payment_mandates p ON p.cart_mandate_id = c.id
284
+ LEFT JOIN %<schema>s.settlements s ON s.cart_mandate_id = c.id
285
+ WHERE c.user_id = $1 AND i.user_id = $1 AND p.user_id = $1
286
+ AND c.mandate_id = $2
287
+ AND c.raw_jws = $3
288
+ AND i.raw_jws = $4
289
+ AND p.raw_jws = $5
290
+ LIMIT 1
291
+ SQL
292
+
293
+ # The signed id goes to `mandate_id`; the row id is server-generated and returned.
294
+ INTENT_INSERT_SQL = <<~SQL.squish
295
+ INSERT INTO %<schema>s.intent_mandates
296
+ (mandate_id, user_id, agent_id, issuer, scope, cap_amount_cents,
297
+ currency, expires_at, created_at, raw_jws)
298
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now(), $9)
299
+ RETURNING id
300
+ SQL
301
+
302
+ # `line_items` arrives as JSON text; the cast stores a jsonb array rather than a string.
303
+ CART_INSERT_SQL = <<~SQL.squish
304
+ INSERT INTO %<schema>s.cart_mandates
305
+ (mandate_id, intent_mandate_id, user_id, agent_id, issuer, line_items,
306
+ total_amount_cents, currency, expires_at, created_at, raw_jws)
307
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, now(), $10)
308
+ RETURNING id
309
+ SQL
310
+
311
+ PAYMENT_INSERT_SQL = <<~SQL.squish
312
+ INSERT INTO %<schema>s.payment_mandates
313
+ (mandate_id, cart_mandate_id, user_id, agent_id, issuer,
314
+ payment_method, amount_cents, currency, expires_at, created_at, raw_jws)
315
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), $10)
316
+ RETURNING id
317
+ SQL
318
+
319
+ # Nobody signs a settlement, so it has no `mandate_id` or `raw_jws`; `UNIQUE (cart_mandate_id)` keeps it single.
320
+ SETTLEMENT_INSERT_SQL = <<~SQL.squish
321
+ INSERT INTO %<schema>s.settlements
322
+ (cart_mandate_id, user_id, agent_id, issuer, psp_reference,
323
+ settled_amount_cents, currency, settled_at)
324
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now())
325
+ RETURNING id
326
+ SQL
327
+
328
+ DECLINE_SQL = <<~SQL.squish
329
+ UPDATE %<schema>s.cart_mandates SET declined_at = now() WHERE id = $1 AND declined_at IS NULL
330
+ SQL
331
+
332
+ private
333
+
334
+ def chain_record(chain)
335
+ connection.exec_query(ledger_sql(CHAIN_SQL), 'Kiosk chain lookup', [
336
+ identity.user_id, chain.cart.id, chain.cart.raw_jws, chain.intent.raw_jws,
337
+ chain.payment.raw_jws
338
+ ]).to_a.first
339
+ end
340
+
341
+ def decline_cart(cart_row)
342
+ connection.exec_query(ledger_sql(DECLINE_SQL), 'Kiosk cart decline', [cart_row])
343
+ end
344
+
345
+ def persist_intent_mandate(intent)
346
+ insert_returning_id('Kiosk intent_mandate insert', INTENT_INSERT_SQL, [
347
+ intent.id, intent.user_id, intent.agent_id, intent.issuer, intent.scope,
348
+ intent.cap_amount_cents.to_i, intent.currency, intent.expires_at, intent.raw_jws
349
+ ])
350
+ end
351
+
352
+ # References the server intent id; the signed intent-cart binding is checked by MandateVerifier.
353
+ def persist_cart_mandate(cart, intent_row_id:)
354
+ insert_returning_id('Kiosk cart_mandate insert', CART_INSERT_SQL, [
355
+ cart.id, intent_row_id, cart.user_id, cart.agent_id, cart.issuer,
356
+ cart.line_items.to_json, cart.total_amount_cents.to_i, cart.currency,
357
+ cart.expires_at, cart.raw_jws
358
+ ])
359
+ end
360
+
361
+ def persist_payment_mandate(cart_row_id:, payment:)
362
+ # No presented payment method means the on-file card funded the charge.
363
+ pm_db = payment.payment_method.to_s.empty? ? 'on_file' : payment.payment_method
364
+ insert_returning_id('Kiosk payment_mandate insert', PAYMENT_INSERT_SQL, [
365
+ payment.id, cart_row_id, payment.user_id, payment.agent_id, payment.issuer,
366
+ pm_db, payment.amount_cents.to_i, payment.currency, payment.expires_at,
367
+ payment.raw_jws
368
+ ])
369
+ end
370
+
371
+ def persist_settlement(cart_row_id:, cart:, settled:)
372
+ insert_returning_id('Kiosk settlement insert', SETTLEMENT_INSERT_SQL, [
373
+ cart_row_id, cart.user_id, cart.agent_id, cart.issuer,
374
+ settled[:psp_reference], settled[:settled_amount_cents].to_i, cart.currency
375
+ ])
376
+ end
377
+
378
+ # Matched by class name so neither ActiveRecord nor PG has to be loaded.
379
+ def unique_violation?(error)
380
+ %w[ActiveRecord::RecordNotUnique PG::UniqueViolation].include?(error.class.name)
381
+ end
382
+
383
+ def ledger_sql(template) = format(template, schema: Kiosk.configuration.schema)
384
+
385
+ # `exec_query`, because `exec_insert` would append a second `RETURNING`.
386
+ def insert_returning_id(name, template, binds)
387
+ connection.exec_query(ledger_sql(template), name, binds).to_a.first.fetch('id')
388
+ end
389
+ end
390
+
391
+ include Pay
392
+ include Repeat
393
+ include SpendingCap
394
+ include Ledger
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Logs the exception whose message a refusal kept off the wire: Rails' logger, else `warn`.
6
+ # A raising logger is swallowed so one failure does not become two.
7
+ module FailureLog
8
+ # Enough frames to reach the operator's own code without flooding the log.
9
+ BACKTRACE_FRAMES = 20
10
+
11
+ # One string to grep for refusals that kept their message off the wire.
12
+ PREFIX = '[kiosk-server]'
13
+
14
+ module_function
15
+
16
+ # `summary` is the caller's own words, the half that also reaches the wire.
17
+ def report(summary, error)
18
+ line = "#{PREFIX} #{summary}: #{error.message}\n " \
19
+ "#{Array(error.backtrace).first(BACKTRACE_FRAMES).join("\n ")}"
20
+ logger = defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
21
+ logger ? logger.error(line) : warn(line)
22
+ nil
23
+ rescue StandardError
24
+ nil
25
+ end
26
+ end
27
+ end
28
+ end