Class: Payment::Apis::Paypal
- Inherits:
-
BaseService
- Object
- BaseService
- Payment::Apis::Paypal
- Includes:
- Singleton
- Defined in:
- app/services/payment/apis/paypal.rb
Overview
Thin wrapper around the PayPal REST APIs (Orders v2, Payments v2,
Invoicing v2, Vault v3). Holds a singleton-cached OAuth access token
under a mutex so concurrent workers share a refresh, and exposes
class-level convenience methods used by Gateways::Paypal
and Gateways::PaypalInvoice.
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: {}) ⇒ HTTP::Client
HTTP client with the cached OAuth access token applied.
-
.authorize_order(order_id) ⇒ Hash
Authorize a created Orders v2 order (the second leg of the JS SDK buy-button flow).
-
.cancel_invoice(invoice_id) ⇒ Hash
Cancel an open hosted invoice and notify both parties.
-
.capture_authorization(authorization_id, amount: nil, currency: nil, final_capture: true, invoice_id: nil, note_to_payer: nil, soft_descriptor: nil) ⇒ Hash
Capture (in whole or part) against a PayPal authorization.
-
.capture_order(order_id) ⇒ Hash
Capture a created Orders v2 order whose intent was
CAPTURE. -
.client ⇒ HTTP::Client
Underlying HTTP.rb client with basic auth pre-applied (used to fetch OAuth tokens).
-
.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: {}) ⇒ Hash
Create a PayPal Orders v2 order using a stored vault token instead of a freshly approved order from the JS SDK.
-
.create_vault_payment_token(setup_token_id) ⇒ Hash
Exchange an approved setup token for a permanent vault payment-method token.
-
.create_vault_setup_token(return_url:, cancel_url:) ⇒ Object
======================================== Vault API v3 — Payment Method Tokens ========================================.
-
.delete_vault_payment_token(token_id) ⇒ Hash{String=>Object}
Delete a stored vault token.
-
.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) ⇒ Hash
Parsed Invoicing v2 invoice.
-
.get_invoice_details(invoice_id) ⇒ OpenStruct
Backward-compatible shim for callers expecting an OpenStruct-like invoice response with
to_sandstatusaccessors. -
.get_json(path) ⇒ Hash
GET
pathand parse the JSON body. -
.get_order(order_id) ⇒ Hash
Parsed Orders v2 response.
-
.get_transaction_details(auth) ⇒ Object
======================================== Reporting ========================================.
-
.get_vault_setup_token(setup_token_id) ⇒ Hash
Read back a vault setup token after the customer has approved it in the JS SDK flow.
-
.hostname ⇒ String
PayPal API hostname (sandbox vs live).
-
.list_vault_payment_tokens(customer_id) ⇒ Hash
List vault tokens stored on a PayPal customer.
-
.merchant_email ⇒ String
PayPal merchant email used as the invoicer on hosted invoices.
-
.parse_response(response) ⇒ Hash
Parse a PayPal HTTP response into a hash that always carries
_http_statusand_http_successso callers can branch without holding the raw HTTP::Response. -
.post_json(path, payload, idempotent: false, headers: {}) ⇒ Hash
POST
payloadas JSON. -
.reauthorize_authorization(authorization_id, amount: nil, currency: nil) ⇒ Hash
Reauthorize an expiring PayPal authorization for a new amount.
-
.refund_capture(capture_id, amount: nil, currency: nil, invoice_id: nil, note_to_payer: nil) ⇒ Hash
Refund (full or partial) a captured PayPal payment.
-
.remind_invoice(invoice_id) ⇒ Hash
Send a "please pay" reminder for an open hosted invoice.
-
.send_invoice(invoice_id, send_to_invoicer: true, send_to_recipient: true) ⇒ Hash
Send a draft hosted invoice to the customer.
-
.void_authorization(authorization_id) ⇒ Hash{Symbol=>Object}
Void a PayPal authorization.
Instance Method Summary collapse
-
#initialize ⇒ Paypal
constructor
A new instance of Paypal.
-
#token_mutex ⇒ Mutex
Singleton-scoped mutex used to serialize OAuth refresh.
- #token_valid? ⇒ Boolean
Constructor Details
#initialize ⇒ Paypal
Returns a new instance of Paypal.
15 16 17 18 19 20 21 22 23 24 |
# File 'app/services/payment/apis/paypal.rb', line 15 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.
13 14 15 |
# File 'app/services/payment/apis/paypal.rb', line 13 def client @client end |
Class Method Details
.authed_client(headers: {}) ⇒ HTTP::Client
HTTP client with the cached OAuth access token applied. Caller
may layer additional headers.
446 447 448 449 450 |
# File 'app/services/payment/apis/paypal.rb', line 446 def self.authed_client(headers: {}) HTTP.auth("Bearer #{fetch_token}") .headers('Content-Type' => 'application/json') .headers(headers) end |
.authorize_order(order_id) ⇒ Hash
Authorize a created Orders v2 order (the second leg of the JS SDK
buy-button flow).
146 147 148 149 |
# File 'app/services/payment/apis/paypal.rb', line 146 def self.(order_id) post_json("/v2/checkout/orders/#{order_id}/authorize", {}, idempotent: true, headers: { 'Prefer' => 'return=representation' }) end |
.cancel_invoice(invoice_id) ⇒ Hash
Cancel an open hosted invoice and notify both parties.
268 269 270 271 272 273 |
# File 'app/services/payment/apis/paypal.rb', line 268 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) ⇒ Hash
Capture (in whole or part) against a PayPal authorization.
177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'app/services/payment/apis/paypal.rb', line 177 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) ⇒ Hash
Capture a created Orders v2 order whose intent was CAPTURE.
155 156 157 |
# File 'app/services/payment/apis/paypal.rb', line 155 def self.capture_order(order_id) post_json("/v2/checkout/orders/#{order_id}/capture", {}, idempotent: true) end |
.client ⇒ HTTP::Client
Returns underlying HTTP.rb client with basic auth
pre-applied (used to fetch OAuth tokens).
33 34 35 |
# File 'app/services/payment/apis/paypal.rb', line 33 def self.client instance.client end |
.create_invoice(request) ⇒ Object
========================================
Invoicing API v2
247 248 249 |
# File 'app/services/payment/apis/paypal.rb', line 247 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
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 94 95 96 97 98 99 100 101 102 103 104 |
# File 'app/services/payment/apis/paypal.rb', line 52 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: {}) ⇒ Hash
Create a PayPal Orders v2 order using a stored vault token instead
of a freshly approved order from the JS SDK.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'app/services/payment/apis/paypal.rb', line 116 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) ⇒ Hash
Exchange an approved setup token for a permanent vault
payment-method token.
323 324 325 326 327 328 329 |
# File 'app/services/payment/apis/paypal.rb', line 323 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
295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'app/services/payment/apis/paypal.rb', line 295 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) ⇒ Hash{String=>Object}
Delete a stored vault token.
343 344 345 346 |
# File 'app/services/payment/apis/paypal.rb', line 343 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.
409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 |
# File 'app/services/payment/apis/paypal.rb', line 409 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
352 353 354 355 356 357 358 359 360 |
# File 'app/services/payment/apis/paypal.rb', line 352 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
163 164 165 |
# File 'app/services/payment/apis/paypal.rb', line 163 def self.() get_json("/v2/payments/authorizations/#{}") end |
.get_authorization_details(payment) ⇒ Object
========================================
Backward-compatible aliases (used by Payment model)
366 367 368 369 |
# File 'app/services/payment/apis/paypal.rb', line 366 def self.(payment) response = (payment.) OpenStruct.new(response.merge('parse' => response)) end |
.get_capture(capture_id) ⇒ Object
========================================
Payments API v2 – Captures & Refunds
220 221 222 |
# File 'app/services/payment/apis/paypal.rb', line 220 def self.get_capture(capture_id) get_json("/v2/payments/captures/#{capture_id}") end |
.get_invoice(invoice_id) ⇒ Hash
Returns parsed Invoicing v2 invoice.
287 288 289 |
# File 'app/services/payment/apis/paypal.rb', line 287 def self.get_invoice(invoice_id) get_json("/v2/invoicing/invoices/#{invoice_id}") end |
.get_invoice_details(invoice_id) ⇒ OpenStruct
Backward-compatible shim for callers expecting an OpenStruct-like
invoice response with to_s and status accessors.
376 377 378 379 |
# File 'app/services/payment/apis/paypal.rb', line 376 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) ⇒ Hash
GET path and parse the JSON body.
471 472 473 474 |
# File 'app/services/payment/apis/paypal.rb', line 471 def self.get_json(path) response = authed_client.get("#{hostname}#{path}") parse_response(response) end |
.get_order(order_id) ⇒ Hash
Returns parsed Orders v2 response.
137 138 139 |
# File 'app/services/payment/apis/paypal.rb', line 137 def self.get_order(order_id) get_json("/v2/checkout/orders/#{order_id}") end |
.get_transaction_details(auth) ⇒ Object
========================================
Reporting
385 386 387 388 389 390 |
# File 'app/services/payment/apis/paypal.rb', line 385 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) ⇒ Hash
Read back a vault setup token after the customer has approved it
in the JS SDK flow.
314 315 316 |
# File 'app/services/payment/apis/paypal.rb', line 314 def self.get_vault_setup_token(setup_token_id) get_json("/v3/vault/setup-tokens/#{setup_token_id}") end |
.hostname ⇒ String
Returns PayPal API hostname (sandbox vs live).
38 39 40 |
# File 'app/services/payment/apis/paypal.rb', line 38 def self.hostname Heatwave::Configuration.fetch(:paypal, :hostname) end |
.list_vault_payment_tokens(customer_id) ⇒ Hash
List vault tokens stored on a PayPal customer.
335 336 337 |
# File 'app/services/payment/apis/paypal.rb', line 335 def self.list_vault_payment_tokens(customer_id) get_json("/v3/vault/payment-tokens?customer_id=#{customer_id}") end |
.merchant_email ⇒ String
Returns PayPal merchant email used as the invoicer on
hosted invoices.
44 45 46 |
# File 'app/services/payment/apis/paypal.rb', line 44 def self.merchant_email Heatwave::Configuration.fetch(:paypal, :email) end |
.parse_response(response) ⇒ Hash
Parse a PayPal HTTP response into a hash that always carries
_http_status and _http_success so callers can branch without
holding the raw HTTP::Response.
482 483 484 485 486 487 488 489 490 |
# File 'app/services/payment/apis/paypal.rb', line 482 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: {}) ⇒ Hash
POST payload as JSON. Adds a PayPal-Request-Id header on
idempotent endpoints so retries don't double-charge.
460 461 462 463 464 465 |
# File 'app/services/payment/apis/paypal.rb', line 460 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) ⇒ Hash
Reauthorize an expiring PayPal authorization for a new amount.
207 208 209 210 211 212 213 214 |
# File 'app/services/payment/apis/paypal.rb', line 207 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) ⇒ Hash
Refund (full or partial) a captured PayPal payment.
232 233 234 235 236 237 238 239 240 241 |
# File 'app/services/payment/apis/paypal.rb', line 232 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) ⇒ Hash
Send a "please pay" reminder for an open hosted invoice.
279 280 281 282 283 |
# File 'app/services/payment/apis/paypal.rb', line 279 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) ⇒ Hash
Send a draft hosted invoice to the customer.
257 258 259 260 261 262 |
# File 'app/services/payment/apis/paypal.rb', line 257 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) ⇒ Hash{Symbol=>Object}
Void a PayPal authorization. Returns a slim status hash because
the void endpoint responds with no body.
195 196 197 198 199 |
# File 'app/services/payment/apis/paypal.rb', line 195 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 ⇒ Mutex
Returns singleton-scoped mutex used to serialize OAuth refresh.
27 28 29 |
# File 'app/services/payment/apis/paypal.rb', line 27 def token_mutex @token_mutex end |
#token_valid? ⇒ Boolean
392 393 394 |
# File 'app/services/payment/apis/paypal.rb', line 392 def token_valid? @token.present? && @token_expires_at.present? && Time.current < @token_expires_at end |