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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- 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
|