payload-api 0.4.1 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +2 -0
  3. data/LICENSE +1 -1
  4. data/README.md +23 -2
  5. data/lib/payload/arm/attr.rb +169 -0
  6. data/lib/payload/arm/object.rb +44 -1
  7. data/lib/payload/arm/request.rb +66 -6
  8. data/lib/payload/arm/session.rb +13 -9
  9. data/lib/payload/exceptions.rb +15 -0
  10. data/lib/payload/objects.rb +81 -18
  11. data/lib/payload/version.rb +2 -2
  12. data/lib/payload.rb +15 -5
  13. data/spec/objects/v1/access_token_spec.rb +19 -0
  14. data/spec/objects/v1/account_spec.rb +97 -0
  15. data/spec/objects/v1/billing_spec.rb +54 -0
  16. data/spec/objects/v1/invoice_spec.rb +53 -0
  17. data/spec/objects/v1/payment_link_spec.rb +50 -0
  18. data/spec/objects/v1/payment_method_spec.rb +106 -0
  19. data/spec/objects/{payment_spec.rb → v1/payment_spec.rb} +5 -6
  20. data/spec/objects/v1/session_spec.rb +89 -0
  21. data/spec/objects/v1/transaction_spec.rb +55 -0
  22. data/spec/objects/v2/account_spec.rb +211 -0
  23. data/spec/objects/v2/invoice_spec.rb +53 -0
  24. data/spec/objects/v2/payment_method_spec.rb +106 -0
  25. data/spec/objects/v2/transaction_spec.rb +48 -0
  26. data/spec/payload/arm/arm_request_query_spec.rb +226 -0
  27. data/spec/payload/arm/attr_spec.rb +216 -0
  28. data/spec/payload/arm/object_spec.rb +114 -0
  29. data/spec/payload/arm/request_format_integration_spec.rb +166 -0
  30. data/spec/payload/arm/request_spec.rb +259 -1
  31. data/spec/payload/arm/session_spec.rb +40 -0
  32. data/spec/payload/exceptions_spec.rb +82 -0
  33. data/spec/support/helpers/v1_helpers.rb +159 -0
  34. data/spec/support/helpers/v2_helpers.rb +205 -0
  35. data/spec/support/helpers.rb +15 -0
  36. data/spec/support/helpers_spec.rb +21 -0
  37. metadata +28 -6
@@ -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)" })
@@ -759,4 +759,262 @@ RSpec.describe Payload::ARMRequest do
759
759
  end
760
760
  end
761
761
  end
762
+
763
+ describe "API version header functionality" do
764
+
765
+ # Mock object for testing
766
+ class MockObject < Payload::ARMObject
767
+ @spec = { 'object' => 'mock_object', 'endpoint' => '/mock' }
768
+ end
769
+
770
+ context "when session.api_version is set" do
771
+ it "includes X-API-Version header in GET requests" do
772
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
773
+
774
+ session = Payload::Session.new('test_key', 'https://api.test.com', '2.1')
775
+ instance = Payload::ARMRequest.new(MockObject, session)
776
+
777
+ expect(instance).to receive(:_execute_request) do |http, request|
778
+ expect(request.method).to eq("GET")
779
+ expect(request['X-API-Version']).to eq('2.1')
780
+
781
+ class MockResponse
782
+ def initialize
783
+ end
784
+
785
+ def code
786
+ '200'
787
+ end
788
+
789
+ def body
790
+ '{
791
+ "object": "mock_object",
792
+ "id": "' + $test_id + '"
793
+ }'
794
+ end
795
+ end
796
+
797
+ MockResponse.new
798
+ end
799
+
800
+ instance.get($test_id)
801
+ end
802
+ end
803
+
804
+ context "when session.api_version is nil" do
805
+ it "does not include X-API-Version header" do
806
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
807
+
808
+ session = Payload::Session.new('test_key', 'https://api.test.com', nil)
809
+ instance = Payload::ARMRequest.new(MockObject, session)
810
+
811
+ expect(instance).to receive(:_execute_request) do |http, request|
812
+ expect(request.method).to eq("GET")
813
+ expect(request['X-API-Version']).to be_nil
814
+
815
+ class MockResponse
816
+ def initialize
817
+ end
818
+
819
+ def code
820
+ '200'
821
+ end
822
+
823
+ def body
824
+ '{
825
+ "object": "mock_object",
826
+ "id": "' + $test_id + '"
827
+ }'
828
+ end
829
+ end
830
+
831
+ MockResponse.new
832
+ end
833
+
834
+ instance.get($test_id)
835
+ end
836
+ end
837
+
838
+ context "when no session is provided" do
839
+ it "uses global Payload.api_version" do
840
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
841
+
842
+ # Set global api_version
843
+ original_version = Payload.api_version
844
+ Payload.api_version = '2.2'
845
+
846
+ # Create request without session (will use global Payload module)
847
+ instance = Payload::ARMRequest.new(MockObject, nil)
848
+
849
+ expect(instance).to receive(:_execute_request) do |http, request|
850
+ expect(request.method).to eq("GET")
851
+ expect(request['X-API-Version']).to eq('2.2')
852
+
853
+ class MockResponse
854
+ def initialize
855
+ end
856
+
857
+ def code
858
+ '200'
859
+ end
860
+
861
+ def body
862
+ '{
863
+ "object": "mock_object",
864
+ "id": "' + $test_id + '"
865
+ }'
866
+ end
867
+ end
868
+
869
+ MockResponse.new
870
+ end
871
+
872
+ instance.get($test_id)
873
+
874
+ # Restore original version
875
+ Payload.api_version = original_version
876
+ end
877
+ end
878
+
879
+ context "when making POST requests" do
880
+ it "includes X-API-Version header" do
881
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
882
+
883
+ session = Payload::Session.new('test_key', 'https://api.test.com', '2.3')
884
+ instance = Payload::ARMRequest.new(MockObject, session)
885
+
886
+ expect(instance).to receive(:_execute_request) do |http, request|
887
+ expect(request.method).to eq("POST")
888
+ expect(request['X-API-Version']).to eq('2.3')
889
+
890
+ class MockResponse
891
+ def initialize
892
+ end
893
+
894
+ def code
895
+ '200'
896
+ end
897
+
898
+ def body
899
+ '{
900
+ "object": "mock_object",
901
+ "id": "' + $test_id + '"
902
+ }'
903
+ end
904
+ end
905
+
906
+ MockResponse.new
907
+ end
908
+
909
+ instance.create({ field: 'value' })
910
+ end
911
+ end
912
+
913
+ context "when making PUT requests" do
914
+ it "includes X-API-Version header" do
915
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
916
+
917
+ session = Payload::Session.new('test_key', 'https://api.test.com', '2.4')
918
+ instance = Payload::ARMRequest.new(MockObject, session)
919
+
920
+ expect(instance).to receive(:_execute_request) do |http, request|
921
+ expect(request.method).to eq("PUT")
922
+ expect(request['X-API-Version']).to eq('2.4')
923
+
924
+ class MockResponse
925
+ def initialize
926
+ end
927
+
928
+ def code
929
+ '200'
930
+ end
931
+
932
+ def body
933
+ '{
934
+ "object": "mock_object",
935
+ "id": "' + $test_id + '"
936
+ }'
937
+ end
938
+ end
939
+
940
+ MockResponse.new
941
+ end
942
+
943
+ instance.update(field: 'new_value')
944
+ end
945
+ end
946
+
947
+ context "when making DELETE requests" do
948
+ it "includes X-API-Version header" do
949
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
950
+
951
+ session = Payload::Session.new('test_key', 'https://api.test.com', '2.5')
952
+
953
+ # Create mock object to delete
954
+ mock_obj = MockObject.new({ id: $test_id })
955
+ mock_obj.set_session(session)
956
+
957
+ expect_any_instance_of(Payload::ARMRequest).to receive(:_execute_request) do |inst, http, request|
958
+ expect(request.method).to eq("DELETE")
959
+ expect(request['X-API-Version']).to eq('2.5')
960
+
961
+ class MockResponse
962
+ def initialize
963
+ end
964
+
965
+ def code
966
+ '200'
967
+ end
968
+
969
+ def body
970
+ '{
971
+ "object": "mock_object",
972
+ "id": "' + $test_id + '"
973
+ }'
974
+ end
975
+ end
976
+
977
+ MockResponse.new
978
+ end
979
+
980
+ mock_obj.delete
981
+ end
982
+ end
983
+
984
+ context "when custom headers are provided" do
985
+ it "merges X-API-Version header with existing headers" do
986
+ $test_id = 'mock_' + rand(9000000...9999999).to_s
987
+
988
+ session = Payload::Session.new('test_key', 'https://api.test.com', '2.6')
989
+ instance = Payload::ARMRequest.new(MockObject, session)
990
+
991
+ expect(instance).to receive(:_execute_request) do |http, request|
992
+ expect(request.method).to eq("POST")
993
+ # Verify both Content-Type and X-API-Version headers are present
994
+ expect(request['Content-Type']).to eq('application/json')
995
+ expect(request['X-API-Version']).to eq('2.6')
996
+
997
+ class MockResponse
998
+ def initialize
999
+ end
1000
+
1001
+ def code
1002
+ '200'
1003
+ end
1004
+
1005
+ def body
1006
+ '{
1007
+ "object": "mock_object",
1008
+ "id": "' + $test_id + '"
1009
+ }'
1010
+ end
1011
+ end
1012
+
1013
+ MockResponse.new
1014
+ end
1015
+
1016
+ instance.create({ field: 'value' })
1017
+ end
1018
+ end
1019
+ end
762
1020
  end
@@ -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
@@ -0,0 +1,159 @@
1
+ class V1Helpers
2
+ attr_reader :session
3
+
4
+ def initialize(session)
5
+ @session = session
6
+ end
7
+
8
+ def create_customer_account(name: 'Test', email: 'test@example.com')
9
+ session.Customer.create(name: name, email: email)
10
+ end
11
+
12
+ def create_processing_account
13
+ session.ProcessingAccount.create(
14
+ name: 'Processing Account',
15
+ legal_entity: {
16
+ legal_name: 'Test',
17
+ type: 'INDIVIDUAL_SOLE_PROPRIETORSHIP',
18
+ ein: '23 423 4234',
19
+ street_address: '123 Example Street',
20
+ unit_number: 'Suite 1',
21
+ city: 'New York',
22
+ state_province: 'NY',
23
+ state_incorporated: 'NY',
24
+ postal_code: '11238',
25
+ country: 'US',
26
+ phone_number: '(111) 222-3333',
27
+ website: 'http://www.payload.com',
28
+ start_date: '05/01/2015',
29
+ contact_name: 'Test Person',
30
+ contact_email: 'test.person@example.com',
31
+ contact_title: 'VP',
32
+ owners: [
33
+ {
34
+ full_name: 'Test Person',
35
+ email: 'test.person@example.com',
36
+ ssn: '234 23 4234',
37
+ birth_date: '06/20/1985',
38
+ title: 'CEO',
39
+ ownership: '100',
40
+ street_address: '123 Main Street',
41
+ unit_number: '#1A',
42
+ city: 'New York',
43
+ state_province: 'NY',
44
+ postal_code: '10001',
45
+ phone_number: '(111) 222-3333',
46
+ type: 'owner'
47
+ }
48
+ ]
49
+ },
50
+ payment_methods: [session.PaymentMethod.new(
51
+ type: 'bank_account',
52
+ bank_account: {
53
+ account_number: '123456789',
54
+ routing_number: '036001808',
55
+ account_type: 'checking'
56
+ }
57
+ )]
58
+ )
59
+ end
60
+
61
+ def create_card_payment(processing_id, amount: nil, description: nil, customer_id: nil, invoice_id: nil)
62
+ payment = {
63
+ processing_id: processing_id,
64
+ amount: amount || rand * 100,
65
+ description: description || 'Test Payment',
66
+ payment_method: session.PaymentMethod.new(
67
+ type: 'card',
68
+ card: {
69
+ card_number: '4242 4242 4242 4242',
70
+ expiry: '12/35',
71
+ card_code: '123',
72
+ },
73
+ billing_address: {
74
+ postal_code: '11111'
75
+ }
76
+ )
77
+ }
78
+ if invoice_id
79
+ payment = payment.merge({
80
+ allocations: [{invoice_id: invoice_id}]
81
+ })
82
+ end
83
+ if customer_id
84
+ payment = payment.merge({
85
+ customer_id: customer_id
86
+ })
87
+ end
88
+ session.Payment.create(payment)
89
+ end
90
+
91
+ def create_bank_payment
92
+ session.Payment.create(
93
+ type: 'payment',
94
+ amount: rand * 1000,
95
+ payment_method: session.PaymentMethod.new(
96
+ type: 'bank_account',
97
+ account_holder: 'First Last',
98
+ bank_account: {
99
+ account_number: '1234567890',
100
+ routing_number: '036001808',
101
+ account_type: 'checking'
102
+ },
103
+ billing_address: {
104
+ postal_code: '11111'
105
+ }
106
+ )
107
+ )
108
+ end
109
+
110
+ def create_invoice(processing_account, customer_account)
111
+ session.Invoice.create(
112
+ processing_id: processing_account.id,
113
+ due_date: Date.today.strftime('%Y-%m-%d'),
114
+ customer_id: customer_account.id,
115
+ items: [session.ChargeItem.new(amount: 29.99)]
116
+ )
117
+ end
118
+
119
+ def create_blind_refund(amount, processing_id)
120
+ session.Refund.create(
121
+ amount: amount,
122
+ processing_id: processing_id,
123
+ payment_method: {
124
+ type: 'card',
125
+ card: {
126
+ card_number: '4242 4242 4242 4242',
127
+ expiry: '12/30',
128
+ card_code: '123'
129
+ },
130
+ billing_address: { postal_code: '11111' }
131
+ }
132
+ )
133
+ end
134
+
135
+ def create_refund(payment, amount: nil)
136
+ session.Refund.create(
137
+ amount: amount || payment.amount,
138
+ ledger: [{assoc_transaction_id: payment.id}]
139
+ )
140
+ end
141
+
142
+ def create_payment_link_one_time(processing_account)
143
+ session.PaymentLink.create(
144
+ type: 'one_time',
145
+ description: 'Payment Request',
146
+ amount: 10.00,
147
+ processing_id: processing_account.id
148
+ )
149
+ end
150
+
151
+ def create_payment_link_reusable(processing_account)
152
+ session.PaymentLink.create(
153
+ type: 'reusable',
154
+ description: 'Payment Request',
155
+ amount: 10.00,
156
+ processing_id: processing_account.id
157
+ )
158
+ end
159
+ end