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 rawHTTP::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.
438 439 440 441 442 |
# File 'app/services/payment/apis/paypal.rb', line 438 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).
145 146 147 148 |
# File 'app/services/payment/apis/paypal.rb', line 145 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.
264 265 266 267 268 269 |
# File 'app/services/payment/apis/paypal.rb', line 264 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.
175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'app/services/payment/apis/paypal.rb', line 175 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.
154 155 156 |
# File 'app/services/payment/apis/paypal.rb', line 154 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
243 244 245 |
# File 'app/services/payment/apis/paypal.rb', line 243 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
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 94 95 96 97 98 99 100 101 102 103 |
# File 'app/services/payment/apis/paypal.rb', line 51 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.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'app/services/payment/apis/paypal.rb', line 115 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.
318 319 320 321 322 323 324 |
# File 'app/services/payment/apis/paypal.rb', line 318 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
290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'app/services/payment/apis/paypal.rb', line 290 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.
338 339 340 341 |
# File 'app/services/payment/apis/paypal.rb', line 338 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
@token_expires_at - calling inst.token_valid? would re-read the live
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.
401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'app/services/payment/apis/paypal.rb', line 401 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
346 347 348 349 350 351 352 353 354 |
# File 'app/services/payment/apis/paypal.rb', line 346 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
161 162 163 |
# File 'app/services/payment/apis/paypal.rb', line 161 def self.() get_json("/v2/payments/authorizations/#{}") end |
.get_authorization_details(payment) ⇒ Object
359 360 361 362 |
# File 'app/services/payment/apis/paypal.rb', line 359 def self.(payment) response = (payment.) OpenStruct.new(response.merge('parse' => response)) end |
.get_capture(capture_id) ⇒ Object
217 218 219 |
# File 'app/services/payment/apis/paypal.rb', line 217 def self.get_capture(capture_id) get_json("/v2/payments/captures/#{capture_id}") end |
.get_invoice(invoice_id) ⇒ Hash
Returns parsed Invoicing v2 invoice.
283 284 285 |
# File 'app/services/payment/apis/paypal.rb', line 283 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.
369 370 371 372 |
# File 'app/services/payment/apis/paypal.rb', line 369 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.
463 464 465 466 |
# File 'app/services/payment/apis/paypal.rb', line 463 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.
136 137 138 |
# File 'app/services/payment/apis/paypal.rb', line 136 def self.get_order(order_id) get_json("/v2/checkout/orders/#{order_id}") end |
.get_transaction_details(auth) ⇒ Object
377 378 379 380 381 382 |
# File 'app/services/payment/apis/paypal.rb', line 377 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.
309 310 311 |
# File 'app/services/payment/apis/paypal.rb', line 309 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.
330 331 332 |
# File 'app/services/payment/apis/paypal.rb', line 330 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.
474 475 476 477 478 479 480 481 482 |
# File 'app/services/payment/apis/paypal.rb', line 474 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.
452 453 454 455 456 457 |
# File 'app/services/payment/apis/paypal.rb', line 452 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.
205 206 207 208 209 210 211 212 |
# File 'app/services/payment/apis/paypal.rb', line 205 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.
229 230 231 232 233 234 235 236 237 238 |
# File 'app/services/payment/apis/paypal.rb', line 229 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.
275 276 277 278 279 |
# File 'app/services/payment/apis/paypal.rb', line 275 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.
253 254 255 256 257 258 |
# File 'app/services/payment/apis/paypal.rb', line 253 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.
193 194 195 196 197 |
# File 'app/services/payment/apis/paypal.rb', line 193 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
384 385 386 |
# File 'app/services/payment/apis/paypal.rb', line 384 def token_valid? @token.present? && @token_expires_at.present? && Time.current < @token_expires_at end |