Class: Payment::Apis::Paypal
- Inherits:
-
BaseService
- Object
- BaseService
- Payment::Apis::Paypal
- Includes:
- Singleton
- Defined in:
- app/services/payment/apis/paypal.rb
Constant Summary collapse
- TOKEN_EXPIRY_BUFFER =
60.seconds
Instance Attribute Summary collapse
-
#client ⇒ Object
readonly
Returns the value of attribute client.
Class Method Summary collapse
- .authed_client(headers: {}) ⇒ Object
- .authorize_order(order_id) ⇒ Object
- .cancel_invoice(invoice_id) ⇒ Object
- .capture_authorization(authorization_id, amount: nil, currency: nil, final_capture: true, invoice_id: nil, note_to_payer: nil, soft_descriptor: nil) ⇒ Object
- .capture_order(order_id) ⇒ Object
- .client ⇒ Object
-
.create_invoice(request) ⇒ Object
======================================== Invoicing API v2 ========================================.
-
.create_order(amount:, currency:, intent: 'AUTHORIZE', line_items: [], shipping: nil, shipping_amount: nil, tax_amount: nil, subtotal: nil, description: nil, buyer_email: nil, metadata: {}, vault_on_success: false) ⇒ Object
======================================== Orders API v2 ========================================.
- .create_order_from_vault(vault_id:, amount:, currency:, intent: 'AUTHORIZE', description: nil, metadata: {}) ⇒ Object
- .create_vault_payment_token(setup_token_id) ⇒ Object
-
.create_vault_setup_token(return_url:, cancel_url:) ⇒ Object
======================================== Vault API v3 — Payment Method Tokens ========================================.
- .delete_vault_payment_token(token_id) ⇒ Object
-
.fetch_token ⇒ Object
Thread-safe OAuth token refresh.
-
.generate_id_token(customer_id: nil) ⇒ Object
======================================== ID Token for JS SDK vault flows ========================================.
-
.get_authorization(authorization_id) ⇒ Object
======================================== Payments API v2 – Authorizations ========================================.
-
.get_authorization_details(payment) ⇒ Object
======================================== Backward-compatible aliases (used by Payment model) ========================================.
-
.get_capture(capture_id) ⇒ Object
======================================== Payments API v2 – Captures & Refunds ========================================.
- .get_invoice(invoice_id) ⇒ Object
- .get_invoice_details(invoice_id) ⇒ Object
- .get_json(path) ⇒ Object
- .get_order(order_id) ⇒ Object
-
.get_transaction_details(auth) ⇒ Object
======================================== Reporting ========================================.
- .get_vault_setup_token(setup_token_id) ⇒ Object
- .hostname ⇒ Object
- .list_vault_payment_tokens(customer_id) ⇒ Object
- .merchant_email ⇒ Object
- .parse_response(response) ⇒ Object
- .post_json(path, payload, idempotent: false, headers: {}) ⇒ Object
- .reauthorize_authorization(authorization_id, amount: nil, currency: nil) ⇒ Object
- .refund_capture(capture_id, amount: nil, currency: nil, invoice_id: nil, note_to_payer: nil) ⇒ Object
- .remind_invoice(invoice_id) ⇒ Object
- .send_invoice(invoice_id, send_to_invoicer: true, send_to_recipient: true) ⇒ Object
- .void_authorization(authorization_id) ⇒ Object
Instance Method Summary collapse
-
#initialize ⇒ Paypal
constructor
A new instance of Paypal.
- #token_mutex ⇒ Object
- #token_valid? ⇒ Boolean
Constructor Details
#initialize ⇒ Paypal
Returns a new instance of Paypal.
10 11 12 13 14 15 16 17 18 19 |
# File 'app/services/payment/apis/paypal.rb', line 10 def initialize = { user: Heatwave::Configuration.fetch(:paypal, :client_id), pass: Heatwave::Configuration.fetch(:paypal, :client_secret) } @client = HTTP.basic_auth(**) @token = nil @token_expires_at = nil @token_mutex = Mutex.new end |
Instance Attribute Details
#client ⇒ Object (readonly)
Returns the value of attribute client.
8 9 10 |
# File 'app/services/payment/apis/paypal.rb', line 8 def client @client end |
Class Method Details
.authed_client(headers: {}) ⇒ Object
341 342 343 344 345 |
# File 'app/services/payment/apis/paypal.rb', line 341 def self.authed_client(headers: {}) HTTP.auth("Bearer #{fetch_token}") .headers('Content-Type' => 'application/json') .headers(headers) end |
.authorize_order(order_id) ⇒ Object
118 119 120 121 |
# File 'app/services/payment/apis/paypal.rb', line 118 def self.(order_id) post_json("/v2/checkout/orders/#{order_id}/authorize", {}, idempotent: true, headers: { 'Prefer' => 'return=representation' }) end |
.cancel_invoice(invoice_id) ⇒ Object
197 198 199 200 201 202 |
# File 'app/services/payment/apis/paypal.rb', line 197 def self.cancel_invoice(invoice_id) post_json("/v2/invoicing/invoices/#{invoice_id}/cancel", { send_to_invoicer: true, send_to_recipient: true }) end |
.capture_authorization(authorization_id, amount: nil, currency: nil, final_capture: true, invoice_id: nil, note_to_payer: nil, soft_descriptor: nil) ⇒ Object
135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'app/services/payment/apis/paypal.rb', line 135 def self.(, amount: nil, currency: nil, final_capture: true, invoice_id: nil, note_to_payer: nil, soft_descriptor: nil) payload = { final_capture: final_capture } if amount.present? && currency.present? payload[:amount] = { currency_code: currency, value: format('%.2f', amount) } end payload[:invoice_id] = invoice_id.to_s.truncate(127) if invoice_id.present? payload[:note_to_payer] = note_to_payer.to_s.truncate(255) if note_to_payer.present? payload[:soft_descriptor] = soft_descriptor.to_s.truncate(22) if soft_descriptor.present? post_json("/v2/payments/authorizations/#{}/capture", payload, idempotent: true, headers: { 'Prefer' => 'return=representation' }) end |
.capture_order(order_id) ⇒ Object
123 124 125 |
# File 'app/services/payment/apis/paypal.rb', line 123 def self.capture_order(order_id) post_json("/v2/checkout/orders/#{order_id}/capture", {}, idempotent: true) end |
.client ⇒ Object
25 26 27 |
# File 'app/services/payment/apis/paypal.rb', line 25 def self.client instance.client end |
.create_invoice(request) ⇒ Object
========================================
Invoicing API v2
186 187 188 |
# File 'app/services/payment/apis/paypal.rb', line 186 def self.create_invoice(request) post_json("/v2/invoicing/invoices", request) end |
.create_order(amount:, currency:, intent: 'AUTHORIZE', line_items: [], shipping: nil, shipping_amount: nil, tax_amount: nil, subtotal: nil, description: nil, buyer_email: nil, metadata: {}, vault_on_success: false) ⇒ Object
========================================
Orders API v2
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'app/services/payment/apis/paypal.rb', line 41 def self.create_order(amount:, currency:, intent: 'AUTHORIZE', line_items: [], shipping: nil, shipping_amount: nil, tax_amount: nil, subtotal: nil, description: nil, buyer_email: nil, metadata: {}, vault_on_success: false) items = line_items.map do |li| { name: li[:name].to_s.truncate(127), sku: li[:sku].to_s.truncate(127), quantity: li[:quantity].to_s, unit_amount: { currency_code: currency, value: format('%.2f', li[:amount]) } } end breakdown = {} breakdown[:item_total] = { currency_code: currency, value: format('%.2f', subtotal) } if subtotal breakdown[:shipping] = { currency_code: currency, value: format('%.2f', shipping_amount) } if shipping_amount breakdown[:tax_total] = { currency_code: currency, value: format('%.2f', tax_amount) } if tax_amount purchase_unit = { amount: { currency_code: currency, value: format('%.2f', amount), breakdown: breakdown.presence }.compact } purchase_unit[:items] = items if items.any? purchase_unit[:description] = description if description.present? purchase_unit[:shipping] = shipping if shipping.present? purchase_unit[:custom_id] = [:order_id].to_s if [:order_id].present? purchase_unit[:invoice_id] = [:invoice_id].to_s if [:invoice_id].present? payload = { intent: intent, purchase_units: [purchase_unit] } payload[:application_context] = { shipping_preference: "SET_PROVIDED_ADDRESS" } if shipping.present? if vault_on_success payload[:payment_source] = { paypal: { attributes: { vault: { store_in_vault: "ON_SUCCESS", usage_type: "MERCHANT", customer_type: "CONSUMER" } } } } end post_json("/v2/checkout/orders", payload, idempotent: true) end |
.create_order_from_vault(vault_id:, amount:, currency:, intent: 'AUTHORIZE', description: nil, metadata: {}) ⇒ Object
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'app/services/payment/apis/paypal.rb', line 95 def self.create_order_from_vault(vault_id:, amount:, currency:, intent: 'AUTHORIZE', description: nil, metadata: {}) purchase_unit = { amount: { currency_code: currency, value: format('%.2f', amount) } } purchase_unit[:description] = description if description.present? purchase_unit[:custom_id] = [:order_id].to_s if [:order_id].present? purchase_unit[:invoice_id] = [:invoice_id].to_s if [:invoice_id].present? payload = { intent: intent, purchase_units: [purchase_unit], payment_source: { token: { type: "PAYMENT_METHOD_TOKEN", id: vault_id } } } post_json("/v2/checkout/orders", payload, idempotent: true) end |
.create_vault_payment_token(setup_token_id) ⇒ Object
236 237 238 239 240 241 242 |
# File 'app/services/payment/apis/paypal.rb', line 236 def self.create_vault_payment_token(setup_token_id) post_json("/v3/vault/payment-tokens", { payment_source: { token: { id: setup_token_id, type: "SETUP_TOKEN" } } }, idempotent: true) end |
.create_vault_setup_token(return_url:, cancel_url:) ⇒ Object
========================================
Vault API v3 — Payment Method Tokens
218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/services/payment/apis/paypal.rb', line 218 def self.create_vault_setup_token(return_url:, cancel_url:) post_json("/v3/vault/setup-tokens", { payment_source: { paypal: { usage_type: "MERCHANT", experience_context: { return_url: return_url, cancel_url: cancel_url } } } }, idempotent: true) end |
.delete_vault_payment_token(token_id) ⇒ Object
248 249 250 251 |
# File 'app/services/payment/apis/paypal.rb', line 248 def self.delete_vault_payment_token(token_id) response = authed_client.delete("#{hostname}/v3/vault/payment-tokens/#{token_id}") { '_http_status' => response.status.code, '_http_success' => response.status.success? } end |
.fetch_token ⇒ Object
Thread-safe OAuth token refresh. Uses double-checked locking on the
singleton's mutex so concurrent callers don't each issue a separate
token request (which both wastes API quota and leaves the loser's
token written second, racing the @token_expires_at update).
The cached-token check uses a local snapshot of BOTH @token and
ivars and could disagree with the previously-snapshotted cached
value if another thread refreshed between the two reads. Without this,
a cold-start caller could see cached=nil but a fresh token_valid? and
return nil to the caller.
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 |
# File 'app/services/payment/apis/paypal.rb', line 309 def self.fetch_token inst = instance cached_token, cached_expires_at = snapshot_token(inst) return cached_token if cached_token_valid?(cached_token, cached_expires_at) inst.token_mutex.synchronize do cached_token, cached_expires_at = snapshot_token(inst) return cached_token if cached_token_valid?(cached_token, cached_expires_at) response = client.post("#{hostname}/v1/oauth2/token", body: "grant_type=client_credentials").parse token = response["access_token"] raise "PayPal OAuth token request failed: #{response.except('access_token').inspect}" if token.blank? expires_in = response["expires_in"].to_i inst.instance_variable_set(:@token, token) inst.instance_variable_set(:@token_expires_at, Time.current + expires_in.seconds - TOKEN_EXPIRY_BUFFER) token end end |
.generate_id_token(customer_id: nil) ⇒ Object
========================================
ID Token for JS SDK vault flows
257 258 259 260 261 262 263 264 265 |
# File 'app/services/payment/apis/paypal.rb', line 257 def self.generate_id_token(customer_id: nil) body = "grant_type=client_credentials&response_type=id_token" body += "&target_customer_id=#{customer_id}" if customer_id.present? response = client.post("#{hostname}/v1/oauth2/token", body: body).parse token = response["id_token"] Rails.logger.error("[PayPal] ID token request failed: #{response.inspect}") if token.blank? token end |
.get_authorization(authorization_id) ⇒ Object
========================================
Payments API v2 – Authorizations
131 132 133 |
# File 'app/services/payment/apis/paypal.rb', line 131 def self.() get_json("/v2/payments/authorizations/#{}") end |
.get_authorization_details(payment) ⇒ Object
========================================
Backward-compatible aliases (used by Payment model)
271 272 273 274 |
# File 'app/services/payment/apis/paypal.rb', line 271 def self.(payment) response = (payment.) OpenStruct.new(response.merge('parse' => response)) end |
.get_capture(capture_id) ⇒ Object
========================================
Payments API v2 – Captures & Refunds
167 168 169 |
# File 'app/services/payment/apis/paypal.rb', line 167 def self.get_capture(capture_id) get_json("/v2/payments/captures/#{capture_id}") end |
.get_invoice(invoice_id) ⇒ Object
210 211 212 |
# File 'app/services/payment/apis/paypal.rb', line 210 def self.get_invoice(invoice_id) get_json("/v2/invoicing/invoices/#{invoice_id}") end |
.get_invoice_details(invoice_id) ⇒ Object
276 277 278 279 |
# File 'app/services/payment/apis/paypal.rb', line 276 def self.get_invoice_details(invoice_id) response = get_invoice(invoice_id) OpenStruct.new(response.merge('to_s' => response.to_json, 'status' => response.dig('status'))) end |
.get_json(path) ⇒ Object
354 355 356 357 |
# File 'app/services/payment/apis/paypal.rb', line 354 def self.get_json(path) response = authed_client.get("#{hostname}#{path}") parse_response(response) end |
.get_order(order_id) ⇒ Object
114 115 116 |
# File 'app/services/payment/apis/paypal.rb', line 114 def self.get_order(order_id) get_json("/v2/checkout/orders/#{order_id}") end |
.get_transaction_details(auth) ⇒ Object
========================================
Reporting
285 286 287 288 289 290 |
# File 'app/services/payment/apis/paypal.rb', line 285 def self.get_transaction_details(auth) tx_id = auth. sd = (auth.created_at - 1.day).strftime('%Y-%m-%dT%H:%M:%S%z') ed = (auth.created_at + 1.hour).strftime('%Y-%m-%dT%H:%M:%S%z') get_json("/v1/reporting/transactions?transaction_id=#{tx_id}&start_date=#{sd}&end_date=#{ed}&sync_mode=false&fields=all") end |
.get_vault_setup_token(setup_token_id) ⇒ Object
232 233 234 |
# File 'app/services/payment/apis/paypal.rb', line 232 def self.get_vault_setup_token(setup_token_id) get_json("/v3/vault/setup-tokens/#{setup_token_id}") end |
.hostname ⇒ Object
29 30 31 |
# File 'app/services/payment/apis/paypal.rb', line 29 def self.hostname Heatwave::Configuration.fetch(:paypal, :hostname) end |
.list_vault_payment_tokens(customer_id) ⇒ Object
244 245 246 |
# File 'app/services/payment/apis/paypal.rb', line 244 def self.list_vault_payment_tokens(customer_id) get_json("/v3/vault/payment-tokens?customer_id=#{customer_id}") end |
.merchant_email ⇒ Object
33 34 35 |
# File 'app/services/payment/apis/paypal.rb', line 33 def self.merchant_email Heatwave::Configuration.fetch(:paypal, :email) end |
.parse_response(response) ⇒ Object
359 360 361 362 363 364 365 366 367 |
# File 'app/services/payment/apis/paypal.rb', line 359 def self.parse_response(response) body = response.body.to_s parsed = body.present? ? JSON.parse(body) : {} parsed['_http_status'] = response.status.code parsed['_http_success'] = response.status.success? parsed rescue JSON::ParserError { '_http_status' => response.status.code, '_http_success' => response.status.success?, '_raw_body' => body } end |
.post_json(path, payload, idempotent: false, headers: {}) ⇒ Object
347 348 349 350 351 352 |
# File 'app/services/payment/apis/paypal.rb', line 347 def self.post_json(path, payload, idempotent: false, headers: {}) extra_headers = headers.dup extra_headers['PayPal-Request-Id'] = SecureRandom.uuid if idempotent response = authed_client(headers: extra_headers).post("#{hostname}#{path}", json: payload) parse_response(response) end |
.reauthorize_authorization(authorization_id, amount: nil, currency: nil) ⇒ Object
154 155 156 157 158 159 160 161 |
# File 'app/services/payment/apis/paypal.rb', line 154 def self.(, amount: nil, currency: nil) payload = {} if amount.present? && currency.present? payload[:amount] = { currency_code: currency, value: format('%.2f', amount) } end post_json("/v2/payments/authorizations/#{}/reauthorize", payload, idempotent: true, headers: { 'Prefer' => 'return=representation' }) end |
.refund_capture(capture_id, amount: nil, currency: nil, invoice_id: nil, note_to_payer: nil) ⇒ Object
171 172 173 174 175 176 177 178 179 180 |
# File 'app/services/payment/apis/paypal.rb', line 171 def self.refund_capture(capture_id, amount: nil, currency: nil, invoice_id: nil, note_to_payer: nil) payload = {} if amount.present? && currency.present? payload[:amount] = { currency_code: currency, value: format('%.2f', amount) } end payload[:invoice_id] = invoice_id.to_s.truncate(127) if invoice_id.present? payload[:note_to_payer] = note_to_payer.to_s.truncate(255) if note_to_payer.present? post_json("/v2/payments/captures/#{capture_id}/refund", payload, idempotent: true, headers: { 'Prefer' => 'return=representation' }) end |
.remind_invoice(invoice_id) ⇒ Object
204 205 206 207 208 |
# File 'app/services/payment/apis/paypal.rb', line 204 def self.remind_invoice(invoice_id) post_json("/v2/invoicing/invoices/#{invoice_id}/remind", { send_to_invoicer: true }) end |
.send_invoice(invoice_id, send_to_invoicer: true, send_to_recipient: true) ⇒ Object
190 191 192 193 194 195 |
# File 'app/services/payment/apis/paypal.rb', line 190 def self.send_invoice(invoice_id, send_to_invoicer: true, send_to_recipient: true) post_json("/v2/invoicing/invoices/#{invoice_id}/send", { send_to_invoicer: send_to_invoicer, send_to_recipient: send_to_recipient }) end |
.void_authorization(authorization_id) ⇒ Object
148 149 150 151 152 |
# File 'app/services/payment/apis/paypal.rb', line 148 def self.() response = authed_client(headers: { 'PayPal-Request-Id' => SecureRandom.uuid }) .post("#{hostname}/v2/payments/authorizations/#{}/void") { success: response.status.success?, status: response.status.code } end |
Instance Method Details
#token_mutex ⇒ Object
21 22 23 |
# File 'app/services/payment/apis/paypal.rb', line 21 def token_mutex @token_mutex end |
#token_valid? ⇒ Boolean
292 293 294 |
# File 'app/services/payment/apis/paypal.rb', line 292 def token_valid? @token.present? && @token_expires_at.present? && Time.current < @token_expires_at end |