payload-api 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66c7eb5c0125eb3e66a5800851c6e66e42ae36138a2b89516b2ae8a823cacc7d
4
- data.tar.gz: 602f23d391904acdb9ddf3540990692f8f54284746d3bba5eb528827f2f802da
3
+ metadata.gz: 74c2fd7f99fba4cf6b0f1950d013debc81c56629a5b280e7ac79e4e4abd88838
4
+ data.tar.gz: abf8b305b33a0e9a5241f5664807842989b7d423781f35606607298c9297d35a
5
5
  SHA512:
6
- metadata.gz: 19b0346676cab8cbd11a997fbb30f2a9ccec35eb16bfe7905da763efc6f5b7efc27002ac828ff0b14041e284bdd10d0cbc093dbb3d09350dd08bb5cd82bfa1af
7
- data.tar.gz: 7abac9292dcc52fe334f4182f59d6c4253242451a5f3e4aec7997399a6469c56caadf3862b511529d08b5e65c5779764ad868b2e06134266500deedbfa0e73f4
6
+ metadata.gz: 0e34c4a1a585078565d876af179d8e9b3344888ca44a08f9465ac2e740f5f112990d8b7564b7c0394e21dc4eae22c9b5e0e804850c23cb39d5ffd789ad055319
7
+ data.tar.gz: ad8721f83c061113c6abcf4e26f7077e9c07b7a4d34b60d8cdaa9d4f61ad599443cc1e8511d46983d7acd368601b6596b42cd6cef9509dd3cc4a0a89c9492202
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License
2
2
 
3
- Copyright (c) 2024 Payload (http://payload.com)
3
+ Copyright (c) 2026 Payload (http://payload.com)
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payload
4
+ class ARMFilter
5
+ attr_reader :attr, :opval, :val
6
+
7
+ def initialize(attr, val)
8
+ @attr = attr.to_s
9
+ @val = val
10
+ @opval = self.class.op + val.to_s
11
+ end
12
+
13
+ def self.op
14
+ ''
15
+ end
16
+
17
+ def |(other)
18
+ raise TypeError, 'invalid type' unless other.is_a?(Payload::ARMFilter)
19
+ raise ArgumentError, '`or` only works on the same attribute' if other.attr != @attr
20
+ joined = [@opval, other.opval].join('|')
21
+ Payload::ARMEqual.new(@attr, joined)
22
+ end
23
+ end
24
+
25
+ class ARMEqual < ARMFilter
26
+ def self.op
27
+ ''
28
+ end
29
+ end
30
+
31
+ class ARMNotEqual < ARMFilter
32
+ def self.op
33
+ '!'
34
+ end
35
+ end
36
+
37
+ class ARMGreaterThan < ARMFilter
38
+ def self.op
39
+ '>'
40
+ end
41
+ end
42
+
43
+ class ARMLessThan < ARMFilter
44
+ def self.op
45
+ '<'
46
+ end
47
+ end
48
+
49
+ class ARMGreaterThanEqual < ARMFilter
50
+ def self.op
51
+ '>='
52
+ end
53
+ end
54
+
55
+ class ARMLessThanEqual < ARMFilter
56
+ def self.op
57
+ '<='
58
+ end
59
+ end
60
+
61
+ class ARMContains < ARMFilter
62
+ def self.op
63
+ '?*'
64
+ end
65
+ end
66
+
67
+ # Root proxy for pl.attr so that pl.attr.name returns an Attr (not Class#name).
68
+ # Session#attr returns this instead of the Attr class to avoid Class/Module methods (e.g. .name) shadowing attribute names.
69
+ class AttrRoot
70
+ def method_missing(name, *args)
71
+ if args.size == 1 && args[0].is_a?(Symbol)
72
+ inner = Attr.new(name.to_s)
73
+ Attr.new(args[0].to_s, inner).call
74
+ else
75
+ Attr.new(name.to_s)
76
+ end
77
+ end
78
+
79
+ def respond_to_missing?(name, include_private = false)
80
+ true
81
+ end
82
+ end
83
+
84
+ # Attribute DSL for select/group_by/order_by and filter expressions.
85
+ # - pl.attr.id -> "id"
86
+ # - pl.attr.created_at(:month) -> "month(created_at)"
87
+ # - pl.attr.amount(:sum) -> "sum(amount)"
88
+ # - pl.attr.sender.account_id -> "sender[account_id]"
89
+ class Attr
90
+ attr_reader :param, :parent
91
+
92
+ class << self
93
+ def method_missing(name, *args)
94
+ new(name.to_s)
95
+ end
96
+
97
+ def respond_to_missing?(name, include_private = false)
98
+ true
99
+ end
100
+ end
101
+
102
+ def initialize(param, parent = nil)
103
+ @param = param.to_s
104
+ @parent = parent
105
+ @is_method = false
106
+ end
107
+
108
+ def key
109
+ @parent ? "#{@parent.key}[#{@param}]" : @param
110
+ end
111
+
112
+ def to_s
113
+ @is_method ? "#{@param}(#{@parent.key})" : key
114
+ end
115
+
116
+ def strip
117
+ to_s.strip
118
+ end
119
+
120
+ def method_missing(name, *args)
121
+ raise "cannot get attr of method" if @is_method
122
+
123
+ if args.size == 1 && args[0].is_a?(Symbol)
124
+ inner = Attr.new(name.to_s, self)
125
+ Attr.new(args[0].to_s, inner).call
126
+ else
127
+ Attr.new(name.to_s, self)
128
+ end
129
+ end
130
+
131
+ def respond_to_missing?(name, include_private = false)
132
+ true
133
+ end
134
+
135
+ # Mark attribute as a function call (e.g. .month(), .sum())
136
+ def call
137
+ @is_method = true
138
+ self
139
+ end
140
+
141
+ def ==(other)
142
+ ARMEqual.new(self, other)
143
+ end
144
+
145
+ def !=(other)
146
+ ARMNotEqual.new(self, other)
147
+ end
148
+
149
+ def >(other)
150
+ ARMGreaterThan.new(self, other)
151
+ end
152
+
153
+ def <(other)
154
+ ARMLessThan.new(self, other)
155
+ end
156
+
157
+ def >=(other)
158
+ ARMGreaterThanEqual.new(self, other)
159
+ end
160
+
161
+ def <=(other)
162
+ ARMLessThanEqual.new(self, other)
163
+ end
164
+
165
+ def contains(other)
166
+ ARMContains.new(self, other)
167
+ end
168
+ end
169
+ end
@@ -29,11 +29,26 @@ module Payload
29
29
  def get(id)
30
30
  return @cls.get(id, :session => @session)
31
31
  end
32
-
32
+
33
33
  def select(*args, **data)
34
34
  @cls.select(*args, **data, session: @session)
35
35
  end
36
36
 
37
+ def order_by(*args, **data)
38
+ @cls.order_by(*args, **data, session: @session)
39
+ end
40
+
41
+ def limit(n, **data)
42
+ @cls.limit(n, **data, session: @session)
43
+ end
44
+
45
+ def offset(n, **data)
46
+ @cls.offset(n, **data, session: @session)
47
+ end
48
+
49
+ def group_by(*args, **data)
50
+ @cls.group_by(*args, **data, session: @session)
51
+ end
37
52
  end
38
53
 
39
54
  class ARMObject
@@ -103,7 +118,7 @@ module Payload
103
118
  end
104
119
 
105
120
  def [](key)
106
- return @data[key]
121
+ return @data[key.to_s]
107
122
  end
108
123
 
109
124
  def _get_request()
@@ -118,6 +133,22 @@ module Payload
118
133
  return self._get_request().select(*args, **data)
119
134
  end
120
135
 
136
+ def self.order_by(*args, **data)
137
+ self._get_request().order_by(*args, **data)
138
+ end
139
+
140
+ def self.limit(n, **data)
141
+ self._get_request().limit(n)
142
+ end
143
+
144
+ def self.offset(n, **data)
145
+ self._get_request().offset(n)
146
+ end
147
+
148
+ def self.group_by(*args, **data)
149
+ self._get_request().group_by(*args, **data)
150
+ end
151
+
121
152
  def self.filter_by(*args, **data)
122
153
  session = data[:session]
123
154
  data.delete(:session)
@@ -154,6 +185,10 @@ module Payload
154
185
  return _get_request()._request('Delete', id: self.id)
155
186
  end
156
187
 
188
+ def json
189
+ to_json
190
+ end
191
+
157
192
  def to_json(*args)
158
193
  serialized = {}
159
194
  if self.class.poly
@@ -1,5 +1,6 @@
1
1
  require "payload/exceptions"
2
2
  require "payload/utils"
3
+ require "payload/arm/attr"
3
4
  require "net/http"
4
5
  require "uri"
5
6
  require "json"
@@ -12,21 +13,46 @@ module Payload
12
13
  @cls = cls
13
14
  @session = session || Payload::Session.new(Payload::api_key, Payload::api_url)
14
15
  @filters = {}
16
+ @group_by = []
17
+ @order_by = []
18
+ @limit = nil
19
+ @offset = nil
20
+ @filter_objects = []
15
21
  end
16
22
 
17
23
  def select(*args, **data)
18
- @filters['fields'] = args.map {|a| a.strip }.join(',')
19
-
24
+ @filters['fields'] = args.map { |a| a.strip }.join(',')
20
25
  return self
21
26
  end
22
27
 
28
+ def group_by(*args, **data)
29
+ @group_by.concat(args)
30
+ self
31
+ end
32
+
33
+ def order_by(*args, **data)
34
+ @order_by.concat(args)
35
+ self
36
+ end
37
+
38
+ def limit(n)
39
+ @limit = n
40
+ self
41
+ end
42
+
43
+ def offset(n)
44
+ @offset = n
45
+ self
46
+ end
47
+
23
48
  def filter_by(*args, **data)
49
+ args.each do |f|
50
+ @filter_objects << f if f.respond_to?(:attr) && f.respond_to?(:opval)
51
+ end
24
52
  if !@cls.nil? && @cls.poly
25
53
  data = data.merge(@cls.poly)
26
54
  end
27
-
28
55
  @filters = @filters.merge(data)
29
-
30
56
  return self
31
57
  end
32
58
 
@@ -35,6 +61,17 @@ module Payload
35
61
  return self._request('Get')
36
62
  end
37
63
 
64
+ def [](key)
65
+ case key
66
+ when Range
67
+ raise ArgumentError, 'Negative slice indices not supported' if key.begin && key.begin < 0
68
+ raise ArgumentError, 'Negative slice indices not supported' if key.end && key.end < 0
69
+ offset(key.begin).limit(key.size).all()
70
+ else
71
+ raise TypeError, "invalid key or index: #{key.inspect}"
72
+ end
73
+ end
74
+
38
75
  def get(id)
39
76
  if id.nil? || id.empty?
40
77
  throw 'id cannot be empty'
@@ -127,6 +164,16 @@ module Payload
127
164
  http.request(request)
128
165
  end
129
166
 
167
+ def request_params
168
+ params = @filters.dup
169
+ @filter_objects.each { |f| params[f.attr] = f.opval }
170
+ @group_by.each_with_index { |v, i| params["group_by[#{i}]"] = v.to_s }
171
+ @order_by.each_with_index { |v, i| params["order_by[#{i}]"] = v.to_s }
172
+ params['limit'] = @limit.to_s if @limit
173
+ params['offset'] = @offset.to_s if @offset
174
+ params
175
+ end
176
+
130
177
  def _request(method, id: nil, json: nil)
131
178
  if !@cls.nil?
132
179
  if @cls.spec.key?("endpoint")
@@ -146,8 +193,9 @@ module Payload
146
193
  endpoint = File.join(endpoint, id)
147
194
  end
148
195
 
196
+ params = request_params
149
197
  url = URI.join(@session.api_url, endpoint)
150
- url.query = URI.encode_www_form(@filters)
198
+ url.query = URI.encode_www_form(params)
151
199
 
152
200
  http = Net::HTTP.new(url.host, url.port)
153
201
 
@@ -1,15 +1,15 @@
1
1
  require 'payload/arm/request'
2
-
2
+ require 'payload/arm/attr'
3
3
 
4
4
  module Payload
5
5
  class Session
6
6
  attr_accessor :api_key, :api_url, :api_version
7
-
7
+
8
8
  def initialize(api_key = nil, api_url = nil, api_version = nil, **kwargs)
9
9
  @api_key = kwargs[:api_key] || api_key
10
10
  @api_url = kwargs[:api_url] || api_url || Payload.URL
11
11
  @api_version = kwargs[:api_version] || api_version || Payload.api_version
12
-
12
+
13
13
  Payload.constants.each do |c|
14
14
  val = Payload.const_get(c)
15
15
  if val.is_a?(Class) && val < Payload::ARMObject
@@ -17,11 +17,15 @@ module Payload
17
17
  end
18
18
  end
19
19
  end
20
-
20
+
21
21
  def _get_request(cls = nil)
22
22
  return Payload::ARMRequest.new(cls, self)
23
23
  end
24
24
 
25
+ def attr
26
+ Payload::AttrRoot.new
27
+ end
28
+
25
29
  def query(cls)
26
30
  return self._get_request(cls)
27
31
  end
@@ -18,6 +18,21 @@ module Payload
18
18
  @code='400'
19
19
  end
20
20
 
21
+ class TransactionDeclined < BadRequest
22
+ attr_reader :transaction
23
+
24
+ def initialize(msg, data = nil)
25
+ super(msg, data)
26
+ @transaction = if data && data['details'].is_a?(Hash)
27
+ cls = Payload.get_cls(data['details'])
28
+ cls = Payload::Transaction if cls.nil?
29
+ cls.new(data['details'], nil)
30
+ else
31
+ nil
32
+ end
33
+ end
34
+ end
35
+
21
36
  class InvalidAttributes < PayloadError
22
37
  @code='400'
23
38
  end
@@ -62,10 +62,18 @@ module Payload
62
62
  @spec = { 'object' => 'invoice' }
63
63
  end
64
64
 
65
+ class InvoiceAttachment < ARMObject
66
+ @spec = { 'object' => 'invoice_attachment' }
67
+ end
68
+
65
69
  class PaymentActivation < ARMObject
66
70
  @spec = { 'object' => 'payment_activation' }
67
71
  end
68
72
 
73
+ class ProcessingSettings < ARMObject
74
+ @spec = { 'object' => 'processing_settings' }
75
+ end
76
+
69
77
  class Webhook < ARMObject
70
78
  @spec = { 'object' => 'webhook' }
71
79
  end
@@ -96,7 +104,7 @@ module Payload
96
104
  class ProcessingAccount < ARMObject
97
105
  @spec = { 'object' => 'processing_account' }
98
106
  end
99
-
107
+
100
108
  class Org < ARMObject
101
109
  @spec = { 'object' => 'org', 'endpoint' => '/account/orgs' }
102
110
  end
@@ -123,7 +131,7 @@ module Payload
123
131
  end
124
132
 
125
133
  class Entity < ARMObject
126
- @spec = { 'object' => 'entity' }
134
+ @spec = { 'object' => 'entity', 'endpoint' => 'entities' }
127
135
  end
128
136
 
129
137
  class Stakeholder < ARMObject
@@ -1,3 +1,3 @@
1
1
  module Payload
2
- VERSION = '0.5.0'
3
- end
2
+ VERSION = '0.6.0'
3
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payload"
4
+ require "payload/arm/object"
5
+
6
+ RSpec.describe Payload::ARMRequest do
7
+ describe "#group_by" do
8
+ let(:instance) { described_class.new(Payload::Invoice, nil) }
9
+
10
+ it "appends to group_by and returns self" do
11
+ year_attr = Payload::Attr.new("year", Payload::Attr.new("created_at"))
12
+ year_attr.call
13
+ result = instance.group_by(year_attr, Payload::Attr.status)
14
+ expect(result).to be(instance)
15
+ expect(instance.instance_variable_get(:@group_by).map(&:to_s)).to eq(["year(created_at)", "status"])
16
+ end
17
+
18
+ it "includes group_by in request params when all() is called" do
19
+ Payload::api_key = "test_key"
20
+ instance.instance_variable_set(:@cls, Payload::Invoice)
21
+
22
+ expect(instance).to receive(:_execute_request) do |_http, request|
23
+ query = request.path.split("?")[1]
24
+ expect(query).to include("group_by%5B0%5D=") # group_by[0]=
25
+ expect(query).to include("group_by%5B1%5D=") # group_by[1]=
26
+ QuerySpecMockResponse.new('{"object":"list","values":[]}')
27
+ end
28
+
29
+ instance.group_by("year(created_at)", "status").all()
30
+ end
31
+ end
32
+
33
+ describe "#order_by" do
34
+ let(:instance) { described_class.new(Payload::Account, nil) }
35
+
36
+ it "appends to order_by and returns self" do
37
+ result = instance.order_by("created_at", "desc(id)")
38
+ expect(result).to be(instance)
39
+ expect(instance.instance_variable_get(:@order_by)).to eq(["created_at", "desc(id)"])
40
+ end
41
+
42
+ it "includes order_by in request params when all() is called" do
43
+ Payload::api_key = "test_key"
44
+ instance.instance_variable_set(:@cls, Payload::Account)
45
+
46
+ expect(instance).to receive(:_execute_request) do |_http, request|
47
+ query = request.path.split("?")[1]
48
+ expect(query).to include("order_by%5B0%5D=created_at")
49
+ expect(query).to include("order_by%5B1%5D=desc%28id%29")
50
+ QuerySpecMockResponse.new('{"object":"list","values":[]}')
51
+ end
52
+
53
+ instance.order_by("created_at", "desc(id)").all()
54
+ end
55
+ end
56
+
57
+ describe "#limit and #offset" do
58
+ let(:instance) { described_class.new(Payload::Account, nil) }
59
+
60
+ it "sets limit and offset and returns self" do
61
+ result = instance.limit(10).offset(20)
62
+ expect(result).to be(instance)
63
+ expect(instance.instance_variable_get(:@limit)).to eq(10)
64
+ expect(instance.instance_variable_get(:@offset)).to eq(20)
65
+ end
66
+
67
+ it "includes limit and offset in request params when all() is called" do
68
+ Payload::api_key = "test_key"
69
+ instance.instance_variable_set(:@cls, Payload::Account)
70
+
71
+ expect(instance).to receive(:_execute_request) do |_http, request|
72
+ query = request.path.split("?")[1]
73
+ expect(query).to include("limit=10")
74
+ expect(query).to include("offset=20")
75
+ QuerySpecMockResponse.new('{"object":"list","values":[]}')
76
+ end
77
+
78
+ instance.limit(10).offset(20).all()
79
+ end
80
+ end
81
+
82
+ describe "#request_params" do
83
+ it "merges filters, filter_objects, group_by, order_by, limit, offset into query params" do
84
+ instance = described_class.new(Payload::Transaction, nil)
85
+ instance.instance_variable_set(:@filters, { "fields" => "id,amount" })
86
+ instance.instance_variable_set(:@group_by, ["status"])
87
+ instance.instance_variable_set(:@order_by, ["desc(created_at)"])
88
+ instance.instance_variable_set(:@limit, 5)
89
+ instance.instance_variable_set(:@offset, 10)
90
+
91
+ filter_obj = Payload::ARMGreaterThan.new(Payload::Attr.amount, 100)
92
+ instance.instance_variable_set(:@filter_objects, [filter_obj])
93
+
94
+ params = instance.request_params
95
+
96
+ expect(params["fields"]).to eq("id,amount")
97
+ expect(params["group_by[0]"]).to eq("status")
98
+ expect(params["order_by[0]"]).to eq("desc(created_at)")
99
+ expect(params["limit"]).to eq("5")
100
+ expect(params["offset"]).to eq("10")
101
+ expect(params[filter_obj.attr]).to eq(filter_obj.opval)
102
+ end
103
+
104
+ it "is encoded as URL query string in _request (url.query = URI.encode_www_form(params))" do
105
+ Payload::api_key = "test_key"
106
+ session = Payload::Session.new("test_key", "https://api.test.com", "v2")
107
+ instance = described_class.new(Payload::Invoice, session)
108
+ instance.select("id", "status").filter_by(session.attr.status == "open").order_by("created_at").limit(5).offset(1)
109
+
110
+ expected_params = instance.request_params.dup
111
+
112
+ expect(instance).to receive(:_execute_request) do |_http, request|
113
+ query_str = request.path.split("?", 2)[1]
114
+ expect(query_str).not_to be_nil
115
+ decoded = URI.decode_www_form(query_str || "").to_h
116
+ expected_params.each do |key, value|
117
+ expect(decoded[key]).to eq(value.to_s)
118
+ end
119
+ expect(decoded).to include("fields" => "id,status", "limit" => "5", "offset" => "1")
120
+ expect(decoded.keys).to include("status", "order_by[0]")
121
+ QuerySpecMockResponse.new('{"object":"list","values":[]}')
122
+ end
123
+
124
+ instance.all()
125
+ end
126
+ end
127
+
128
+ describe "#[] (slice)" do
129
+ let(:instance) { described_class.new(Payload::Account, nil) }
130
+
131
+ it "raises TypeError for non-Range key" do
132
+ expect { instance["foo"] }.to raise_error(TypeError, /invalid key or index/)
133
+ expect { instance[5] }.to raise_error(TypeError, /invalid key or index/)
134
+ end
135
+
136
+ it "raises ArgumentError for negative begin" do
137
+ expect { instance[-1..10] }.to raise_error(ArgumentError, /Negative slice indices not supported/)
138
+ end
139
+
140
+ it "raises ArgumentError for negative end" do
141
+ expect { instance[0..-5] }.to raise_error(ArgumentError, /Negative slice indices not supported/)
142
+ end
143
+
144
+ it "calls offset(begin).limit(size).all() for a range and returns result" do
145
+ Payload::api_key = "test_key"
146
+ instance.instance_variable_set(:@cls, Payload::Account)
147
+
148
+ expect(instance).to receive(:_execute_request) do |_http, request|
149
+ query = request.path.split("?")[1]
150
+ expect(query).to include("offset=10")
151
+ expect(query).to include("limit=10")
152
+ QuerySpecMockResponse.new('{"object":"list","values":[{"id":"acct_1","object":"customer"}]}')
153
+ end
154
+
155
+ result = instance[10..19]
156
+ expect(result).to be_an(Array)
157
+ expect(result.size).to eq(1)
158
+ expect(result[0].id).to eq("acct_1")
159
+ end
160
+ end
161
+
162
+ describe "#filter_by with pl.attr filter objects" do
163
+ it "extracts ARM filter objects and merges their attr/opval into request params" do
164
+ session = Payload::Session.new("test_key", "https://api.test.com", "v2")
165
+ instance = described_class.new(Payload::Transaction, session)
166
+ pl = session
167
+ filter_expr = (pl.attr.amount > 100) | (pl.attr.amount < 200)
168
+
169
+ instance.filter_by(filter_expr)
170
+
171
+ expect(instance.instance_variable_get(:@filter_objects)).to include(be_a(Payload::ARMFilter))
172
+ params = instance.request_params
173
+ expect(params.keys).to include(filter_expr.attr)
174
+ expect(params[filter_expr.attr]).to eq(filter_expr.opval)
175
+ end
176
+
177
+ it "builds GET request with filter object serialized in query string" do
178
+ Payload::api_key = "test_key"
179
+ session = Payload::Session.new("test_key", "https://api.test.com", "v2")
180
+ instance = described_class.new(Payload::Transaction, session)
181
+ filter_expr = session.attr.amount > 100
182
+
183
+ instance.filter_by(filter_expr)
184
+
185
+ expect(instance).to receive(:_execute_request) do |_http, request|
186
+ query = request.path.split("?")[1]
187
+ expect(query).to include("amount=")
188
+ expect(query).to include("%3E100") # URL-encoded ">100"
189
+ QuerySpecMockResponse.new('{"object":"list","values":[]}')
190
+ end
191
+
192
+ instance.all()
193
+ end
194
+ end
195
+
196
+ describe "#filter_by with multiple filters" do
197
+ it "accumulates multiple filter objects and merges keyword filters into @filters" do
198
+ instance = described_class.new(Payload::Invoice, nil)
199
+ f1 = Payload::ARMEqual.new(Payload::Attr.status, "open")
200
+ f2 = Payload::ARMGreaterThan.new(Payload::Attr.amount, 50)
201
+
202
+ instance.filter_by(f1).filter_by(f2).filter_by(custom_key: "value")
203
+
204
+ fo = instance.instance_variable_get(:@filter_objects)
205
+ expect(fo).to include(f1, f2)
206
+ params = instance.request_params
207
+ expect(params["status"]).to eq("open")
208
+ expect(params["amount"]).to eq(">50")
209
+ expect(instance.instance_variable_get(:@filters)[:custom_key]).to eq("value")
210
+ end
211
+ end
212
+ end
213
+
214
+ class QuerySpecMockResponse
215
+ def initialize(body = '{"object":"list","values":[]}')
216
+ @body = body
217
+ end
218
+
219
+ def code
220
+ "200"
221
+ end
222
+
223
+ def body
224
+ @body
225
+ end
226
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payload"
4
+ require "payload/arm/attr"
5
+
6
+ RSpec.describe Payload::AttrRoot do
7
+ let(:root) { described_class.new }
8
+
9
+ describe "attribute access" do
10
+ it "returns an Attr for any method name" do
11
+ expect(root.id).to be_a(Payload::Attr)
12
+ expect(root.id.to_s).to eq("id")
13
+ expect(root.created_at).to be_a(Payload::Attr)
14
+ expect(root.created_at.to_s).to eq("created_at")
15
+ end
16
+
17
+ it "returns an Attr for chained property access (non-callable names stay as nested key)" do
18
+ chained = root.sender.account_id
19
+ expect(chained).to be_a(Payload::Attr)
20
+ expect(chained.to_s).to eq("sender[account_id]")
21
+ end
22
+
23
+ it "treats no-arg access as nested attribute" do
24
+ expect(root.created_at.month.to_s).to eq("created_at[month]")
25
+ expect(root.created_at.year.to_s).to eq("created_at[year]")
26
+ end
27
+
28
+ it "supports pl.attr.created_at(:month) style (function as symbol arg)" do
29
+ expect(root.created_at(:month).to_s).to eq("month(created_at)")
30
+ expect(root.created_at(:year).to_s).to eq("year(created_at)")
31
+ expect(root.amount(:sum).to_s).to eq("sum(amount)")
32
+ end
33
+
34
+ it "supports nested attr with symbol: pl.attr.totals.total(:sum) => sum(totals[total])" do
35
+ expect(root.totals.total(:sum).to_s).to eq("sum(totals[total])")
36
+ end
37
+ end
38
+ end
39
+
40
+ RSpec.describe Payload::Attr do
41
+ describe "simple attribute" do
42
+ it "has key and to_s as the param when no parent" do
43
+ attr = Payload::Attr.new("amount")
44
+ expect(attr.key).to eq("amount")
45
+ expect(attr.to_s).to eq("amount")
46
+ end
47
+ end
48
+
49
+ describe "nested attribute" do
50
+ it "builds key and to_s as parent[param]" do
51
+ parent = Payload::Attr.new("totals")
52
+ attr = Payload::Attr.new("total", parent)
53
+ expect(attr.key).to eq("totals[total]")
54
+ expect(attr.to_s).to eq("totals[total]")
55
+ end
56
+ end
57
+
58
+ describe "function form (after .call)" do
59
+ it "serializes as name(parent_key) when marked as method" do
60
+ parent = Payload::Attr.new("created_at")
61
+ attr = Payload::Attr.new("month", parent)
62
+ attr.call
63
+ expect(attr.to_s).to eq("month(created_at)")
64
+ end
65
+
66
+ it "serializes nested path in function form" do
67
+ parent = Payload::Attr.new("total", Payload::Attr.new("totals"))
68
+ attr = Payload::Attr.new("sum", parent)
69
+ attr.call
70
+ expect(attr.to_s).to eq("sum(totals[total])")
71
+ end
72
+
73
+ it "raises when chaining after a method" do
74
+ parent = Payload::Attr.new("created_at")
75
+ attr = Payload::Attr.new("month", parent)
76
+ attr.call
77
+ expect { attr.day }.to raise_error(RuntimeError, /cannot get attr of method/)
78
+ end
79
+ end
80
+
81
+ describe "#strip" do
82
+ it "returns to_s.strip so Attr works with request.select(*args, **data) and args.map(&:strip)" do
83
+ inner = Payload::Attr.new("created_at")
84
+ year_attr = Payload::Attr.new("year", inner)
85
+ year_attr.call
86
+ expect(year_attr.strip).to eq("year(created_at)")
87
+ end
88
+ end
89
+
90
+ describe "comparisons (return ARMFilter subclasses)" do
91
+ let(:attr_amount) { Payload::Attr.new("amount") }
92
+ let(:attr_status) { Payload::Attr.new("status") }
93
+
94
+ it "== returns ARMEqual with correct attr and opval" do
95
+ f = attr_status == "processed"
96
+ expect(f).to be_a(Payload::ARMEqual)
97
+ expect(f.attr).to eq("status")
98
+ expect(f.opval).to eq("processed")
99
+ end
100
+
101
+ it "!= returns ARMNotEqual with ! prefix" do
102
+ f = attr_status != "draft"
103
+ expect(f).to be_a(Payload::ARMNotEqual)
104
+ expect(f.attr).to eq("status")
105
+ expect(f.opval).to eq("!draft")
106
+ end
107
+
108
+ it "> returns ARMGreaterThan" do
109
+ f = attr_amount > 100
110
+ expect(f).to be_a(Payload::ARMGreaterThan)
111
+ expect(f.attr).to eq("amount")
112
+ expect(f.opval).to eq(">100")
113
+ end
114
+
115
+ it "< returns ARMLessThan" do
116
+ f = attr_amount < 500
117
+ expect(f).to be_a(Payload::ARMLessThan)
118
+ expect(f.opval).to eq("<500")
119
+ end
120
+
121
+ it ">= returns ARMGreaterThanEqual" do
122
+ f = attr_amount >= 100
123
+ expect(f).to be_a(Payload::ARMGreaterThanEqual)
124
+ expect(f.opval).to eq(">=100")
125
+ end
126
+
127
+ it "<= returns ARMLessThanEqual" do
128
+ f = attr_amount <= 100
129
+ expect(f).to be_a(Payload::ARMLessThanEqual)
130
+ expect(f.opval).to eq("<=100")
131
+ end
132
+
133
+ it "contains returns ARMContains with ?* op" do
134
+ attr = Payload::Attr.new("description")
135
+ f = attr.contains("INV -")
136
+ expect(f).to be_a(Payload::ARMContains)
137
+ expect(f.attr).to eq("description")
138
+ expect(f.opval).to eq("?*INV -")
139
+ end
140
+
141
+ it "comparisons use Attr key for nested paths" do
142
+ nested = Payload::Attr.new("total", Payload::Attr.new("totals"))
143
+ f = nested > 0
144
+ expect(f.attr).to eq("totals[total]")
145
+ expect(f.opval).to eq(">0")
146
+ end
147
+ end
148
+
149
+ describe "Attr class-level (Payload::Attr.name)" do
150
+ it "returns Attr via method_missing" do
151
+ expect(Payload::Attr.created_at).to be_a(Payload::Attr)
152
+ expect(Payload::Attr.created_at.to_s).to eq("created_at")
153
+ end
154
+ end
155
+ end
156
+
157
+ RSpec.describe Payload::ARMFilter do
158
+ describe "#| (OR)" do
159
+ it "combines two filters on the same attribute and returns ARMEqual with joined opval" do
160
+ left = Payload::ARMGreaterThan.new(Payload::Attr.amount, 100)
161
+ right = Payload::ARMLessThan.new(Payload::Attr.amount, 50)
162
+ combined = left | right
163
+ expect(combined).to be_a(Payload::ARMEqual)
164
+ expect(combined.attr).to eq("amount")
165
+ expect(combined.opval).to eq(">100|<50")
166
+ end
167
+
168
+ it "raises TypeError when other is not an ARMFilter" do
169
+ f = Payload::ARMEqual.new(Payload::Attr.status, "active")
170
+ expect { f | "invalid" }.to raise_error(TypeError, /invalid type/)
171
+ end
172
+
173
+ it "raises ArgumentError when attributes differ" do
174
+ f1 = Payload::ARMEqual.new(Payload::Attr.status, "active")
175
+ f2 = Payload::ARMEqual.new(Payload::Attr.type, "payment")
176
+ expect { f1 | f2 }.to raise_error(ArgumentError, /only works on the same attribute/)
177
+ end
178
+ end
179
+
180
+ describe "filter subclasses op values" do
181
+ it "ARMEqual has empty op" do
182
+ f = Payload::ARMEqual.new("status", "active")
183
+ expect(f.opval).to eq("active")
184
+ end
185
+
186
+ it "ARMNotEqual has ! prefix" do
187
+ f = Payload::ARMNotEqual.new("status", "draft")
188
+ expect(f.opval).to eq("!draft")
189
+ end
190
+
191
+ it "ARMGreaterThan has > prefix" do
192
+ f = Payload::ARMGreaterThan.new("amount", 100)
193
+ expect(f.opval).to eq(">100")
194
+ end
195
+
196
+ it "ARMLessThan has < prefix" do
197
+ f = Payload::ARMLessThan.new("amount", 200)
198
+ expect(f.opval).to eq("<200")
199
+ end
200
+
201
+ it "ARMGreaterThanEqual has >= prefix" do
202
+ f = Payload::ARMGreaterThanEqual.new("amount", 100)
203
+ expect(f.opval).to eq(">=100")
204
+ end
205
+
206
+ it "ARMLessThanEqual has <= prefix" do
207
+ f = Payload::ARMLessThanEqual.new("amount", 100)
208
+ expect(f.opval).to eq("<=100")
209
+ end
210
+
211
+ it "ARMContains has ?* prefix" do
212
+ f = Payload::ARMContains.new("email", "example.com")
213
+ expect(f.opval).to eq("?*example.com")
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payload"
4
+ require "payload/arm/object"
5
+
6
+ RSpec.describe Payload::ARMObject do
7
+ describe "method_missing (attribute access)" do
8
+ let(:session) { Payload::Session.new("test_key", "https://api.test.com", "v2") }
9
+
10
+ it "returns value for present key" do
11
+ obj = Payload::Invoice.new({ "id" => "inv_1", "amount" => 100 }, session)
12
+ expect(obj.id).to eq("inv_1")
13
+ expect(obj.amount).to eq(100)
14
+ end
15
+
16
+ it "raises NoMethodError for missing key (strict behavior preserved)" do
17
+ obj = Payload::Invoice.new({ "id" => "inv_1" }, session)
18
+ expect { obj.nonexistent_key }.to raise_error(NoMethodError, /nonexistent_key/)
19
+ expect { obj.amount }.to raise_error(NoMethodError, /amount/)
20
+ end
21
+
22
+ it "strips trailing = for setter-like names and returns value" do
23
+ obj = Payload::Account.new({ "name" => "Acme" }, session)
24
+ expect(obj.name).to eq("Acme")
25
+ end
26
+ end
27
+
28
+ describe "#json" do
29
+ let(:session) { Payload::Session.new("test_key", "https://api.test.com", "v2") }
30
+
31
+ it "returns same as to_json" do
32
+ obj = Payload::Invoice.new({ "id" => "inv_1", "object" => "invoice" }, session)
33
+ expect(obj.json).to eq(obj.to_json)
34
+ expect(obj.json).to include("inv_1")
35
+ expect(obj.json).to include("invoice")
36
+ end
37
+ end
38
+
39
+ describe ".order_by, .limit, .offset" do
40
+ it "delegates to ARMRequest and returns chainable request" do
41
+ req = Payload::Invoice.order_by("created_at")
42
+ expect(req).to be_a(Payload::ARMRequest)
43
+ expect(req.instance_variable_get(:@order_by)).to include("created_at")
44
+
45
+ req = Payload::Invoice.limit(10)
46
+ expect(req.instance_variable_get(:@limit)).to eq(10)
47
+
48
+ req = Payload::Invoice.offset(20)
49
+ expect(req.instance_variable_get(:@offset)).to eq(20)
50
+ end
51
+ end
52
+
53
+ describe ".select" do
54
+ it "delegates to ARMRequest and sets fields filter" do
55
+ req = Payload::Invoice.select("id", "amount")
56
+ expect(req).to be_a(Payload::ARMRequest)
57
+ expect(req.instance_variable_get(:@filters)["fields"]).to eq("id,amount")
58
+ end
59
+ end
60
+
61
+ describe "#[] (bracket access)" do
62
+ let(:session) { Payload::Session.new("test_key", "https://api.test.com", "v2") }
63
+
64
+ it "returns value for string key" do
65
+ obj = Payload::Invoice.new({ "id" => "inv_1", "status" => "open" }, session)
66
+ expect(obj["id"]).to eq("inv_1")
67
+ expect(obj["status"]).to eq("open")
68
+ end
69
+
70
+ it "returns value for symbol key (data keys are stored as strings)" do
71
+ obj = Payload::Invoice.new({ "id" => "inv_1" }, session)
72
+ expect(obj[:id]).to eq("inv_1")
73
+ end
74
+
75
+ it "returns nil for missing key" do
76
+ obj = Payload::Invoice.new({ "id" => "inv_1" }, session)
77
+ expect(obj["missing"]).to be_nil
78
+ end
79
+ end
80
+
81
+ describe "#to_json" do
82
+ let(:session) { Payload::Session.new("test_key", "https://api.test.com", "v2") }
83
+
84
+ it "includes @data in JSON output" do
85
+ obj = Payload::Invoice.new({ "id" => "inv_1", "object" => "invoice", "amount" => 99 }, session)
86
+ json = obj.to_json
87
+ expect(json).to include("inv_1")
88
+ expect(json).to include("invoice")
89
+ expect(json).to include("99")
90
+ end
91
+
92
+ it "merges class poly when present" do
93
+ obj = Payload::Payment.new({ "id" => "txn_1", "object" => "transaction", "type" => "payment" }, session)
94
+ json = obj.to_json
95
+ expect(json).to include("payment")
96
+ expect(json).to include("txn_1")
97
+ end
98
+ end
99
+
100
+ describe "#respond_to_missing?" do
101
+ let(:session) { Payload::Session.new("test_key", "https://api.test.com", "v2") }
102
+
103
+ it "returns true for key present in data" do
104
+ obj = Payload::Invoice.new({ "id" => "inv_1", "amount" => 100 }, session)
105
+ expect(obj.respond_to?(:id)).to be true
106
+ expect(obj.respond_to?(:amount)).to be true
107
+ end
108
+
109
+ it "returns false for key missing from data (so method_missing will call super)" do
110
+ obj = Payload::Invoice.new({ "id" => "inv_1" }, session)
111
+ expect(obj.respond_to?(:nonexistent_key)).to be false
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payload"
4
+ require "payload/arm/object"
5
+ require_relative "../../support/helpers"
6
+
7
+ RSpec.describe "confirm request format is valid" do
8
+ include_context "test helpers"
9
+
10
+ [1, 2].each do |api_version|
11
+ context "API v#{api_version}" do
12
+ let(:session) { Payload::Session.new(Payload.api_key, Payload.api_url, api_version) }
13
+ let(:pl) { session }
14
+
15
+ context "date functions" do
16
+ it "filter with year(attr) ==" do
17
+ results = pl.Transaction.filter_by(
18
+ pl.attr.type == "payment",
19
+ pl.attr.created_at(:year) == Time.now.year
20
+ ).limit(5).all
21
+ expect(results).to be_an(Array)
22
+ end
23
+
24
+ it "filter with month(attr) ==" do
25
+ results = pl.Transaction.filter_by(
26
+ pl.attr.created_at(:month) == Time.now.month
27
+ ).limit(5).all
28
+ expect(results).to be_an(Array)
29
+ end
30
+
31
+ it "select with dayname(attr)" do
32
+ results = pl.Transaction.select(
33
+ pl.attr.id,
34
+ pl.attr.created_at(:dayname)
35
+ ).limit(5).all
36
+ expect(results).to be_an(Array)
37
+ end
38
+
39
+ it "group_by year(attr) with count" do
40
+ yearly = pl.Transaction.select(
41
+ pl.attr.created_at(:year),
42
+ pl.attr.id(:count)
43
+ ).group_by(pl.attr.created_at(:year)).limit(5).all
44
+ expect(yearly).to be_an(Array)
45
+ end
46
+ end
47
+
48
+ context "order_by" do
49
+ it "order_by ascending" do
50
+ results = pl.Transaction.select(pl.attr.id).order_by(pl.attr.created_at).limit(5).all
51
+ expect(results).to be_an(Array)
52
+ end
53
+
54
+ it "order_by descending" do
55
+ results = pl.Transaction.select(pl.attr.id).order_by(pl.attr.created_at(:desc)).limit(5).all
56
+ expect(results).to be_an(Array)
57
+ end
58
+ end
59
+
60
+ context "limit, offset, and range" do
61
+ it "limit" do
62
+ results = pl.Transaction.select(pl.attr.id).limit(2).all
63
+ expect(results).to be_an(Array)
64
+ expect(results.length).to be <= 2
65
+ end
66
+
67
+ it "offset" do
68
+ page1 = pl.Transaction.select(pl.attr.id).limit(3).offset(0).all
69
+ page2 = pl.Transaction.select(pl.attr.id).limit(3).offset(3).all
70
+ expect(page1).to be_an(Array)
71
+ expect(page2).to be_an(Array)
72
+ expect(page1.length).to be <= 3
73
+ expect(page2.length).to be <= 3
74
+ end
75
+
76
+ it "range operator []" do
77
+ results = pl.Transaction.select(pl.attr.id)[0..2]
78
+ expect(results).to be_an(Array)
79
+ expect(results.length).to be <= 3
80
+ end
81
+ end
82
+
83
+ context "nested attr" do
84
+ it "select with nested attr (sender.account_id)" do
85
+ results = pl.Transaction.select(
86
+ pl.attr.id,
87
+ pl.attr.sender.account_id
88
+ ).filter_by(pl.attr.type == "payment").limit(5).all
89
+ expect(results).to be_an(Array)
90
+ end
91
+ end
92
+
93
+ context "filter operators" do
94
+ it "filter with >" do
95
+ results = pl.Transaction.filter_by(
96
+ pl.attr.type == "payment",
97
+ pl.attr.amount > 0
98
+ ).select(pl.attr.id, pl.attr.amount).limit(5).all
99
+ expect(results).to be_an(Array)
100
+ end
101
+
102
+ it "filter with <" do
103
+ results = pl.Transaction.filter_by(
104
+ pl.attr.type == "payment",
105
+ pl.attr.amount < 1_000_000
106
+ ).select(pl.attr.id, pl.attr.amount).limit(5).all
107
+ expect(results).to be_an(Array)
108
+ end
109
+
110
+ it "filter with !=" do
111
+ results = pl.Transaction.filter_by(
112
+ pl.attr.type != "nonexistent_type"
113
+ ).select(pl.attr.id, pl.attr.type).limit(5).all
114
+ expect(results).to be_an(Array)
115
+ end
116
+
117
+ it "filter with contains" do
118
+ results = pl.Transaction.filter_by(
119
+ pl.attr.type == "payment",
120
+ pl.attr.description.contains("")
121
+ ).select(pl.attr.id, pl.attr.description).limit(5).all
122
+ expect(results).to be_an(Array)
123
+ end
124
+ end
125
+
126
+ context "filter OR (chained conditions)" do
127
+ it "filter with OR (|) on same attribute" do
128
+ or_filter = (pl.attr.amount > 0) | (pl.attr.amount < 1_000_000)
129
+ results = pl.Transaction.filter_by(
130
+ pl.attr.type == "payment",
131
+ or_filter
132
+ ).select(pl.attr.id, pl.attr.amount).limit(5).all
133
+ expect(results).to be_an(Array)
134
+ end
135
+ end
136
+
137
+ context "group_by and aggregates" do
138
+ it "group_by with sum and count" do
139
+ results = pl.Transaction.select(
140
+ pl.attr.type,
141
+ pl.attr.amount(:sum),
142
+ pl.attr.id(:count)
143
+ ).filter_by(pl.attr.type == "payment").group_by(pl.attr.type).limit(5).all
144
+ expect(results).to be_an(Array)
145
+ end
146
+
147
+ it "group_by month(attr) with sum and count" do
148
+ results = pl.Transaction.select(
149
+ pl.attr.created_at(:month),
150
+ pl.attr.amount(:sum),
151
+ pl.attr.id(:count)
152
+ ).filter_by(pl.attr.type == "payment").group_by(pl.attr.created_at(:month)).limit(5).all
153
+ expect(results).to be_an(Array)
154
+ end
155
+
156
+ it "group_by year(attr) with count" do
157
+ results = pl.Transaction.select(
158
+ pl.attr.created_at(:year),
159
+ pl.attr.id(:count)
160
+ ).filter_by(pl.attr.type == "payment").group_by(pl.attr.created_at(:year)).limit(5).all
161
+ expect(results).to be_an(Array)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -11,7 +11,7 @@ RSpec.describe Payload::ARMRequest do
11
11
 
12
12
  context "when the user selects custom fields" do
13
13
  it "selects the requested fields" do
14
- instance.select(' name', 'age ')
14
+ instance.select('name', 'age')
15
15
  expect(instance.instance_variable_get(:@filters)).to eq({ "fields" => "name,age" })
16
16
  instance.select('count(id)', 'sum(amount)')
17
17
  expect(instance.instance_variable_get(:@filters)).to eq({ "fields" => "count(id),sum(amount)" })
@@ -24,6 +24,46 @@ RSpec.describe Payload::Session do
24
24
  end
25
25
  end
26
26
 
27
+ describe "#attr" do
28
+ it "returns an AttrRoot so pl.attr.name returns an Attr (not shadowed by Class#name)" do
29
+ instance = described_class.new("test_key", "https://api.hello.co")
30
+ root = instance.attr
31
+
32
+ expect(root).to be_a(Payload::AttrRoot)
33
+ expect(root.id).to be_a(Payload::Attr)
34
+ expect(root.id.to_s).to eq("id")
35
+
36
+ expect(root.created_at(:year)).to be_a(Payload::Attr)
37
+ expect(root.created_at(:year).to_s).to eq("year(created_at)")
38
+
39
+ expect(root.created_at.year.to_s).to eq("created_at[year]")
40
+ end
41
+ end
42
+
43
+ describe "query chaining with session" do
44
+ it "passes session through query -> select -> order_by -> limit chain" do
45
+ instance = described_class.new("session_key", "https://api.test.com", "v2")
46
+ req = instance.query(Payload::Invoice).select("id", "amount").order_by("created_at").limit(5)
47
+
48
+ expect(req).to be_a(Payload::ARMRequest)
49
+ expect(req.instance_variable_get(:@session)).to eq(instance)
50
+ expect(req.instance_variable_get(:@filters)["fields"]).to eq("id,amount")
51
+ expect(req.instance_variable_get(:@order_by)).to include("created_at")
52
+ expect(req.instance_variable_get(:@limit)).to eq(5)
53
+ end
54
+
55
+ it "filter_by with session.attr uses AttrRoot from same session" do
56
+ instance = described_class.new("test_key", "https://api.test.com", "v2")
57
+ filter_expr = instance.attr.status == "processed"
58
+ req = instance.query(Payload::Transaction).filter_by(filter_expr)
59
+
60
+ expect(req.instance_variable_get(:@session)).to eq(instance)
61
+ expect(req.instance_variable_get(:@filter_objects)).to include(be_a(Payload::ARMEqual))
62
+ params = req.request_params
63
+ expect(params["status"]).to eq("processed")
64
+ end
65
+ end
66
+
27
67
  describe "#query" do
28
68
 
29
69
  context "when the user queries an ARMObject with a session" do
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "payload"
4
+
5
+ RSpec.describe Payload::TransactionDeclined do
6
+ def api_error_payload(message:, details: nil)
7
+ {
8
+ "object" => "error",
9
+ "error_type" => "TransactionDeclined",
10
+ "error_description" => message,
11
+ "details" => details,
12
+ }.compact
13
+ end
14
+
15
+ it "inherits from BadRequest (HTTP 400)" do
16
+ expect(described_class.superclass).to eq(Payload::BadRequest)
17
+ expect(Payload::BadRequest.code).to eq("400")
18
+ end
19
+
20
+ it "sets message from first argument (same as request.rb: data['error_description'])" do
21
+ e = described_class.new("Card declined", nil)
22
+ expect(e.message).to eq("Card declined")
23
+ end
24
+
25
+ it "exposes #transaction as nil when data is nil" do
26
+ e = described_class.new("Declined", nil)
27
+ expect(e.transaction).to be_nil
28
+ end
29
+
30
+ it "exposes #transaction as nil when details is missing" do
31
+ e = described_class.new("Declined", api_error_payload(message: "Declined"))
32
+ expect(e.transaction).to be_nil
33
+ end
34
+
35
+ it "exposes #transaction as nil when details is not a Hash" do
36
+ e = described_class.new("Declined", api_error_payload(message: "Declined", details: "string"))
37
+ expect(e.transaction).to be_nil
38
+ end
39
+
40
+ it "builds a transaction object from data['details'] when present (API-realistic payload)" do
41
+ details = {
42
+ "id" => "txn_123",
43
+ "object" => "transaction",
44
+ "type" => "payment",
45
+ "status" => "declined",
46
+ "status_code" => "do_not_honor",
47
+ "amount" => 100.0,
48
+ }
49
+ data = api_error_payload(message: "Transaction was declined", details: details)
50
+ e = described_class.new(data["error_description"], data)
51
+
52
+ expect(e.transaction).to be_a(Payload::ARMObject)
53
+ expect(e.transaction.id).to eq("txn_123")
54
+ expect(e.transaction["status"]).to eq("declined")
55
+ expect(e.transaction["status_code"]).to eq("do_not_honor")
56
+ expect(e.transaction["amount"]).to eq(100.0)
57
+ expect(e.transaction).to be_a(Payload::Payment)
58
+ end
59
+
60
+ it "uses Transaction when get_cls returns nil for details (minimal details, no object key)" do
61
+ details = { "id" => "txn_456", "status" => "declined" }
62
+ data = api_error_payload(message: "Declined", details: details)
63
+ e = described_class.new(data["error_description"], data)
64
+
65
+ expect(e.transaction).to be_a(Payload::Transaction)
66
+ expect(e.transaction.id).to eq("txn_456")
67
+ end
68
+
69
+ it "matches how ARM request raises: message from error_description, data = full response" do
70
+ data = {
71
+ "object" => "error",
72
+ "error_type" => "TransactionDeclined",
73
+ "error_description" => "There was an issue processing the payment",
74
+ "details" => { "id" => "txn_789", "object" => "transaction", "status" => "declined" },
75
+ }
76
+ e = described_class.new(data["error_description"], data)
77
+
78
+ expect(e.message).to eq("There was an issue processing the payment")
79
+ expect(e.transaction).to be_a(Payload::ARMObject)
80
+ expect(e.transaction.id).to eq("txn_789")
81
+ end
82
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: payload-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Payload
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-21 00:00:00.000000000 Z
11
+ date: 2026-02-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A simple library to interface with the Payload API. See https://docs.payload.com
14
14
  for details.
@@ -22,6 +22,7 @@ files:
22
22
  - LICENSE
23
23
  - README.md
24
24
  - lib/payload.rb
25
+ - lib/payload/arm/attr.rb
25
26
  - lib/payload/arm/object.rb
26
27
  - lib/payload/arm/request.rb
27
28
  - lib/payload/arm/session.rb
@@ -43,8 +44,13 @@ files:
43
44
  - spec/objects/v2/invoice_spec.rb
44
45
  - spec/objects/v2/payment_method_spec.rb
45
46
  - spec/objects/v2/transaction_spec.rb
47
+ - spec/payload/arm/arm_request_query_spec.rb
48
+ - spec/payload/arm/attr_spec.rb
49
+ - spec/payload/arm/object_spec.rb
50
+ - spec/payload/arm/request_format_integration_spec.rb
46
51
  - spec/payload/arm/request_spec.rb
47
52
  - spec/payload/arm/session_spec.rb
53
+ - spec/payload/exceptions_spec.rb
48
54
  - spec/support/helpers.rb
49
55
  - spec/support/helpers/v1_helpers.rb
50
56
  - spec/support/helpers/v2_helpers.rb