spree_stripe 1.8.0 → 1.8.1

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: 6ec2a5a3cd5e0a0b8e5160018ad055ff16dd27a2eea3e8404e4906e83b9e0758
4
- data.tar.gz: b51ad1b1e21337ad780f5e2269c1850b0fd1dfb39ac778db187ae0629c933944
3
+ metadata.gz: cd7ae7fd51255dec03bb8bd709ceae4e9440dc8a61d64a62086fc91950937af0
4
+ data.tar.gz: 7f0c2798878679d21e6337269912e5a5be047c57348255b4536cf2f4de23ed87
5
5
  SHA512:
6
- metadata.gz: f3f47966c0982f865f7dfa55074f4df6c2f8c90e2c5bdb656012266eb7c46ed690260b47fab15d7fabf0889fbaef8d22fbd153548d23f4316bcc6cdca4353d64
7
- data.tar.gz: 4c9696c085fa47b26f0eb6c6dd06f4b855e0de02dd7ec583efea2c552e8be03c9d9f2952500001357ae04c06e0315a65374846a833d2267a7b6ed89b4b25e325
6
+ metadata.gz: edb953569b6fdab13d67ef13128f9edd29e2ce85a5a06f5ae7d1a4a2019dcf8a47aa313452474388ae983e2ccaacbbd8f9003dd8070ba34215fc5464a522fd1f
7
+ data.tar.gz: 77c47bb3f2d62f3ef0b6cbc48430dc834d6921c1a495aa293b7f3f8c82f25f17703c3a2cc0c3e7522d59e3008fedd5781bb3e9aeafb0d2183e0cf15d6e43cd8e
data/Rakefile CHANGED
@@ -21,7 +21,4 @@ task :test_app do
21
21
  install_storefront: true,
22
22
  install_admin: true
23
23
  )
24
- system({ 'BUNDLE_GEMFILE' => File.expand_path('Gemfile', __dir__) }, 'rails g spree_posts:install')
25
- system({ 'BUNDLE_GEMFILE' => File.expand_path('Gemfile', __dir__) }, 'rails g spree_legacy_api_v2:install')
26
- system({ 'BUNDLE_GEMFILE' => File.expand_path('Gemfile', __dir__) }, 'rails g spree_legacy_product_properties:install')
27
24
  end
@@ -21,6 +21,13 @@ module SpreeStripe
21
21
  "cart_#{Spree::PrefixedId::SQIDS.encode([order.id])}"
22
22
  end
23
23
 
24
+ # Server-generated v3 Store API path for the order's cart, passed to the
25
+ # storefront JS so proxied/prefixed deployments resolve the API correctly.
26
+ # @return [String]
27
+ def stripe_cart_api_path(order)
28
+ spree.api_v3_store_cart_path(stripe_cart_prefixed_id(order))
29
+ end
30
+
24
31
  # The store's publishable Spree API key, used by the storefront JS as the
25
32
  # `X-Spree-API-Key` header for v3 Store API calls. Creates one on first use
26
33
  # when the store has no active publishable key yet.
@@ -10,15 +10,16 @@ const ZERO_DECIMAL_CURRENCIES = new Set([
10
10
  ])
11
11
 
12
12
  // Apple Pay / Google Pay express checkout on the v3 Store API. The wallet sheet
13
- // drives address + shipping selection against `/api/v3/store/carts`, then a
14
- // payment session is created and confirmed; completion is handled server-side by
15
- // ConfirmPaymentsController via the `return_url` (same as the regular flow).
13
+ // drives address + shipping selection against the server-provided cart API
14
+ // path, then a payment session is created and confirmed; completion is handled
15
+ // server-side by ConfirmPaymentsController via the `return_url` (same as the
16
+ // regular flow).
16
17
  export default class extends Controller {
17
18
  static values = {
18
19
  apiKey: String,
19
20
  spreeApiKey: String,
20
21
  paymentMethodId: String,
21
- cartId: String,
22
+ cartApiPath: String,
22
23
  cartToken: String,
23
24
  confirmPaymentUrl: String,
24
25
  currency: String,
@@ -38,6 +39,7 @@ export default class extends Controller {
38
39
  connect() {
39
40
  this.isGooglePay = false
40
41
  this.shippingRateMap = new Map()
42
+ this.deliveryMethodMap = new Map()
41
43
  this.initStripe()
42
44
  }
43
45
 
@@ -139,8 +141,9 @@ export default class extends Controller {
139
141
  })
140
142
  if (!cart) return event.reject()
141
143
 
142
- const { shippingRates, selectionMap } = this.buildShippingRates(cart.fulfillments || [])
144
+ const { shippingRates, selectionMap, methodMap } = this.buildShippingRates(cart.fulfillments || [])
143
145
  this.shippingRateMap = selectionMap
146
+ this.deliveryMethodMap = methodMap
144
147
 
145
148
  if (shippingRates.length === 0) return event.reject()
146
149
 
@@ -215,6 +218,17 @@ export default class extends Controller {
215
218
  return
216
219
  }
217
220
 
221
+ // The address patch above rebuilds fulfillments with their default rate,
222
+ // discarding the wallet's selection — re-select it before charging, or the
223
+ // order completes with the wrong delivery method and total.
224
+ if (this.shippingRequiredValue && event.shippingRate) {
225
+ const reselected = await this.reselectDeliveryRates(cart, event.shippingRate.id)
226
+ if (!reselected) {
227
+ event.paymentFailed({ reason: 'fail' })
228
+ return
229
+ }
230
+ }
231
+
218
232
  const { error: submitError } = await this.elements.submit()
219
233
  if (submitError) {
220
234
  event.paymentFailed({ reason: 'fail' })
@@ -259,7 +273,7 @@ export default class extends Controller {
259
273
  // --- v3 Store API calls -------------------------------------------------
260
274
 
261
275
  async patchCart(body) {
262
- const response = await fetch(this.cartApiBase, {
276
+ const response = await fetch(this.cartApiPathValue, {
263
277
  method: 'PATCH',
264
278
  headers: this.spreeApiHeaders,
265
279
  body: JSON.stringify(body)
@@ -268,7 +282,7 @@ export default class extends Controller {
268
282
  }
269
283
 
270
284
  async patchFulfillment(fulfillmentId, deliveryRateId) {
271
- const response = await fetch(`${this.cartApiBase}/fulfillments/${fulfillmentId}`, {
285
+ const response = await fetch(`${this.cartApiPathValue}/fulfillments/${fulfillmentId}`, {
272
286
  method: 'PATCH',
273
287
  headers: this.spreeApiHeaders,
274
288
  body: JSON.stringify({ selected_delivery_rate_id: deliveryRateId })
@@ -277,7 +291,7 @@ export default class extends Controller {
277
291
  }
278
292
 
279
293
  async createPaymentSession(stripePaymentMethodId) {
280
- const response = await fetch(`${this.cartApiBase}/payment_sessions`, {
294
+ const response = await fetch(`${this.cartApiPathValue}/payment_sessions`, {
281
295
  method: 'POST',
282
296
  headers: this.spreeApiHeaders,
283
297
  body: JSON.stringify({
@@ -304,11 +318,14 @@ export default class extends Controller {
304
318
  return items
305
319
  }
306
320
 
307
- // Builds deduped Stripe shipping rates (by delivery_method_id) and a map from
308
- // each Stripe rate id to the per-fulfillment delivery rate ids to select.
321
+ // Builds deduped Stripe shipping rates (by delivery_method_id), a map from
322
+ // each Stripe rate id to the per-fulfillment delivery rate ids to select,
323
+ // and a map back to the delivery_method_id (the only id that survives a
324
+ // fulfillment rebuild — see reselectDeliveryRates).
309
325
  buildShippingRates(fulfillments) {
310
326
  const rateMap = new Map()
311
327
  const selectionMap = new Map()
328
+ const methodMap = new Map()
312
329
 
313
330
  for (const fulfillment of fulfillments) {
314
331
  for (const rate of fulfillment.delivery_rates || []) {
@@ -320,6 +337,7 @@ export default class extends Controller {
320
337
  const id = this.isGooglePay ? `${methodId}-${this.randomSuffix()}` : String(methodId)
321
338
  rateMap.set(methodId, { id, displayName: rate.name, amount: this.toCents(rate.cost) })
322
339
  selectionMap.set(id, [])
340
+ methodMap.set(id, methodId)
323
341
  } else {
324
342
  rateMap.get(methodId).amount += this.toCents(rate.cost)
325
343
  }
@@ -329,7 +347,26 @@ export default class extends Controller {
329
347
  }
330
348
  }
331
349
 
332
- return { shippingRates: Array.from(rateMap.values()), selectionMap }
350
+ return { shippingRates: Array.from(rateMap.values()), selectionMap, methodMap }
351
+ }
352
+
353
+ // Re-selects the delivery method chosen in the wallet sheet on freshly
354
+ // rebuilt fulfillments. Matches by delivery_method_id: fulfillment and rate
355
+ // ids do not survive the rebuild that a shipping-address update triggers
356
+ // server-side (checkout reverts to the address step and shipments are
357
+ // recreated with their default rate).
358
+ async reselectDeliveryRates(cart, stripeRateId) {
359
+ const methodId = this.deliveryMethodMap.get(stripeRateId)
360
+ if (!methodId) return false
361
+
362
+ for (const fulfillment of cart.fulfillments || []) {
363
+ const rate = (fulfillment.delivery_rates || []).find((r) => r.delivery_method_id === methodId)
364
+ if (!rate || rate.selected) continue
365
+
366
+ const updated = await this.patchFulfillment(fulfillment.id, rate.id)
367
+ if (!updated) return false
368
+ }
369
+ return true
333
370
  }
334
371
 
335
372
  // True when every shippable fulfillment has at least one delivery rate — i.e.
@@ -384,10 +421,6 @@ export default class extends Controller {
384
421
  }
385
422
  }
386
423
 
387
- get cartApiBase() {
388
- return `/api/v3/store/carts/${this.cartIdValue}`
389
- }
390
-
391
424
  get spreeApiHeaders() {
392
425
  return {
393
426
  'X-Spree-API-Key': this.spreeApiKeyValue,
@@ -10,7 +10,7 @@ export default class extends Controller {
10
10
  static values = {
11
11
  apiKey: String,
12
12
  clientSecret: String,
13
- cartId: String,
13
+ cartApiPath: String,
14
14
  cartToken: String,
15
15
  spreeApiKey: String,
16
16
  orderEmail: String,
@@ -134,7 +134,7 @@ export default class extends Controller {
134
134
  return false
135
135
  }
136
136
 
137
- const response = await fetch(this.cartApiBase, {
137
+ const response = await fetch(this.cartApiPathValue, {
138
138
  method: 'PATCH',
139
139
  headers: this.spreeApiHeaders,
140
140
  body: JSON.stringify(body)
@@ -224,10 +224,6 @@ export default class extends Controller {
224
224
  return null
225
225
  }
226
226
 
227
- get cartApiBase() {
228
- return `/api/v3/store/carts/${this.cartIdValue}`
229
- }
230
-
231
227
  get spreeApiHeaders() {
232
228
  return {
233
229
  'X-Spree-API-Key': this.spreeApiKeyValue,
@@ -1,13 +1,49 @@
1
1
  module SpreeStripe
2
+ # Backports Stripe's credit-card fingerprint dedup onto Spree::CreditCard for
3
+ # spree < 5.6.0, which doesn't ship it. spree >= 5.6.0 provides the column,
4
+ # scope, and validation in core, so this is only prepended (below) when core
5
+ # lacks it. (Wallet metadata accessors already live in core since 5.4, so they
6
+ # don't need backporting here.)
2
7
  module CreditCardDecorator
3
8
  def self.prepended(base)
4
- base.store_accessor :private_metadata, :wallet
9
+ # Matches saved cards by the gateway's stable card fingerprint plus expiry,
10
+ # used to reuse an existing card instead of saving a duplicate when a gateway
11
+ # issues a fresh payment method id for the same physical card.
12
+ #
13
+ # @param fingerprint [String] gateway card fingerprint
14
+ # @param month [Integer, String] expiry month
15
+ # @param year [Integer, String] expiry year
16
+ base.scope :by_fingerprint, ->(fingerprint, month, year) {
17
+ cards_table = Spree::CreditCard.table_name
18
+
19
+ where(fingerprint: fingerprint).
20
+ where("CAST(#{cards_table}.month AS DECIMAL) = ?", month).
21
+ where("CAST(#{cards_table}.year AS DECIMAL) = ?", year)
22
+ }
23
+
24
+ # Prevents saving the same physical card (same gateway fingerprint + expiry)
25
+ # twice for a user and payment method. Skipped when the gateway does not
26
+ # provide a fingerprint.
27
+ base.validate :fingerprint_not_duplicated, if: -> { fingerprint.present? }
5
28
  end
6
29
 
7
- def wallet_type
8
- wallet&.[]('type')
30
+ private
31
+
32
+ # month/year are varchar columns exposed as integer attributes, so the
33
+ # match must go through #by_fingerprint's decimal cast rather than a plain
34
+ # equality (which Postgres rejects as `character varying = integer`).
35
+ def fingerprint_not_duplicated
36
+ duplicates = self.class.where(user_id: user_id, payment_method_id: payment_method_id).
37
+ by_fingerprint(fingerprint, month, year)
38
+ duplicates = duplicates.where.not(id: id) if persisted?
39
+
40
+ errors.add(:fingerprint, :taken) if duplicates.exists?
9
41
  end
10
42
  end
11
43
  end
12
44
 
13
- Spree::CreditCard.prepend(SpreeStripe::CreditCardDecorator)
45
+ # spree >= 5.6.0 ships fingerprint dedup in core, so only prepend the backport
46
+ # when it's absent (spree < 5.6.0).
47
+ unless Spree::CreditCard.respond_to?(:by_fingerprint)
48
+ Spree::CreditCard.prepend(SpreeStripe::CreditCardDecorator)
49
+ end
@@ -3,21 +3,23 @@ module SpreeStripe
3
3
  def self.prepended(base)
4
4
  base.store_accessor :private_metadata, :stripe_apple_pay_domain_id
5
5
  base.store_accessor :private_metadata, :stripe_top_level_domain_id
6
+
7
+ base.after_commit :register_stripe_domain, on: :update, if: -> { code_previously_changed? }
6
8
  end
7
9
 
8
10
  def stripe_gateway
9
11
  @stripe_gateway ||= payment_methods.stripe.active.last
10
12
  end
11
13
 
12
- def handle_code_changes
13
- super
14
-
15
- SpreeStripe::RegisterDomainJob.perform_later(id)
16
- end
17
-
18
14
  def billing_name
19
15
  name
20
16
  end
17
+
18
+ private
19
+
20
+ def register_stripe_domain
21
+ SpreeStripe::RegisterDomainJob.perform_later(id)
22
+ end
21
23
  end
22
24
  end
23
25
 
@@ -47,14 +47,33 @@ module SpreeStripe
47
47
 
48
48
  def find_or_create_credit_card
49
49
  if user
50
- source = user.credit_cards.find_by(gateway_payment_profile_id: stripe_payment_method_id)
50
+ exact_source = user.credit_cards.find_by(gateway_payment_profile_id: stripe_payment_method_id)
51
+ return exact_source if exact_source
51
52
 
52
- return source if source
53
+ matching_source = match_credit_card_by_fingerprint
54
+ return matching_source if matching_source
53
55
  end
54
56
 
55
57
  Spree::CreditCard.create!(credit_card_params)
56
58
  end
57
59
 
60
+ # Stripe issues a new PaymentMethod (pm_xxx) on every new credit card entry, even for the same physical card,
61
+ # so deduping on gateway_payment_profile_id alone misses repeat cards. Stripe's card.fingerprint
62
+ # is stable for the same card number, so we match on fingerprint + expiry (per Stripe guidance) to
63
+ # reuse the existing saved card instead of creating a duplicate.
64
+ #
65
+ # @return [Spree::CreditCard, nil] the existing saved card for this physical card, or nil
66
+ def match_credit_card_by_fingerprint
67
+ card = stripe_payment_method_details.card
68
+ return if card.fingerprint.blank?
69
+
70
+ user.credit_cards.
71
+ where(payment_method: gateway).
72
+ by_fingerprint(card.fingerprint, card.exp_month, card.exp_year).
73
+ order(created_at: :desc).
74
+ first
75
+ end
76
+
58
77
  def credit_card_params
59
78
  card_details = stripe_payment_method_details.card
60
79
  customer = gateway.fetch_or_create_customer(user: user, order: order)
@@ -65,6 +84,7 @@ module SpreeStripe
65
84
  payment_method: gateway,
66
85
  gateway_customer_profile_id: customer&.profile_id,
67
86
  gateway_payment_profile_id: stripe_payment_method_id,
87
+ fingerprint: card_details.fingerprint,
68
88
  name: stripe_billing_details.name,
69
89
  month: card_details.exp_month,
70
90
  year: card_details.exp_year,
@@ -3,7 +3,7 @@
3
3
  data-controller="checkout-stripe"
4
4
  data-checkout-stripe-api-key-value="<%= current_stripe_gateway.try(:preferred_publishable_key) %>"
5
5
  data-checkout-stripe-client-secret-value="<%= current_stripe_payment_session.client_secret %>"
6
- data-checkout-stripe-cart-id-value="<%= stripe_cart_prefixed_id(@order) %>"
6
+ data-checkout-stripe-cart-api-path-value="<%= stripe_cart_api_path(@order) %>"
7
7
  data-checkout-stripe-cart-token-value="<%= @order.token %>"
8
8
  data-checkout-stripe-spree-api-key-value="<%= current_store_publishable_api_key %>"
9
9
  data-checkout-stripe-order-email-value="<%= @order.email %>"
@@ -6,7 +6,7 @@
6
6
  checkout_stripe_button_api_key_value: current_stripe_gateway.try(:preferred_publishable_key),
7
7
  checkout_stripe_button_spree_api_key_value: current_store_publishable_api_key,
8
8
  checkout_stripe_button_payment_method_id_value: current_stripe_gateway.prefixed_id,
9
- checkout_stripe_button_cart_id_value: stripe_cart_prefixed_id(@order),
9
+ checkout_stripe_button_cart_api_path_value: stripe_cart_api_path(@order),
10
10
  checkout_stripe_button_cart_token_value: @order.token,
11
11
  checkout_stripe_button_confirm_payment_url_value: spree.stripe_confirm_payment_url(@order.prefixed_id),
12
12
  checkout_stripe_button_currency_value: @order.currency.downcase,
@@ -0,0 +1,52 @@
1
+ class AddFingerprintToSpreeCreditCards < ActiveRecord::Migration[7.2]
2
+ INDEX_NAME = 'index_spree_credit_cards_unique_fingerprint'.freeze
3
+
4
+ # spree >= 5.6.0 ships this same migration in core (same name, so Rails' migration
5
+ # installer skips this copy). On spree < 5.6.0 it runs here, giving the gateway a
6
+ # place to store Stripe's card fingerprint for dedup. Guarded so it stays a no-op
7
+ # if the column/index already exist.
8
+ def up
9
+ add_column :spree_credit_cards, :fingerprint, :string, if_not_exists: true
10
+
11
+ return if index_name_exists?(:spree_credit_cards, INDEX_NAME)
12
+
13
+ # Prevent duplicate saved cards (same gateway fingerprint + expiry) per user and
14
+ # payment method at the database level. Only active, fingerprinted cards are
15
+ # constrained, so legacy/non-gateway cards (NULL fingerprint) and soft-deleted
16
+ # rows are left untouched.
17
+ if ActiveRecord::Base.connection.adapter_name == 'Mysql2'
18
+ # MySQL has no partial indexes, but treats NULL as distinct in unique indexes,
19
+ # so NULL fingerprints are naturally allowed. COALESCE on deleted_at keeps
20
+ # soft-deleted rows from colliding with active ones.
21
+ execute <<-SQL
22
+ CREATE UNIQUE INDEX #{INDEX_NAME}
23
+ ON spree_credit_cards(
24
+ user_id,
25
+ payment_method_id,
26
+ fingerprint,
27
+ month,
28
+ year,
29
+ (COALESCE(deleted_at, CAST('1970-01-01' AS DATETIME)))
30
+ )
31
+ SQL
32
+ else
33
+ add_index :spree_credit_cards, [:user_id, :payment_method_id, :fingerprint, :month, :year],
34
+ unique: true,
35
+ where: 'fingerprint IS NOT NULL AND deleted_at IS NULL',
36
+ name: INDEX_NAME
37
+ end
38
+ end
39
+
40
+ def down
41
+ # From spree 5.6.0 the fingerprint column AND its unique index live in core (both
42
+ # share the same names), and Rails' migration installer dedupes this migration
43
+ # against core's — so on 5.6.0+ this migration never created them. Leave core's
44
+ # schema untouched there, which also covers an app that added the column here on
45
+ # older spree and later upgraded: the column is now core-owned and must survive
46
+ # a rollback.
47
+ return if Gem::Version.new(Spree.version) >= Gem::Version.new('5.6.0')
48
+
49
+ remove_index :spree_credit_cards, name: INDEX_NAME if index_name_exists?(:spree_credit_cards, INDEX_NAME)
50
+ remove_column :spree_credit_cards, :fingerprint if column_exists?(:spree_credit_cards, :fingerprint)
51
+ end
52
+ end
@@ -1,7 +1,7 @@
1
1
  FactoryBot.define do
2
2
  factory :stripe_payment_session, class: 'Spree::PaymentSessions::Stripe' do
3
3
  order { create(:order_with_line_items) }
4
- payment_method { create(:stripe_gateway, stores: [order.store]) }
4
+ payment_method { create(:stripe_gateway, store: order.store) }
5
5
  amount { order.total }
6
6
  currency { order.currency }
7
7
  status { 'pending' }
@@ -1,5 +1,5 @@
1
1
  module SpreeStripe
2
- VERSION = '1.8.0'.freeze
2
+ VERSION = '1.8.1'.freeze
3
3
 
4
4
  def gem_version
5
5
  Gem::Version.new(VERSION)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_stripe
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vendo Connect Inc., Vendo Sp. z o.o.
@@ -227,6 +227,7 @@ files:
227
227
  - config/locales/en.yml
228
228
  - config/routes.rb
229
229
  - db/migrate/20250310152812_setup_spree_stripe_models.rb
230
+ - db/migrate/20260610120000_add_fingerprint_to_spree_credit_cards.rb
230
231
  - lib/generators/spree_stripe/install/install_generator.rb
231
232
  - lib/spree_stripe.rb
232
233
  - lib/spree_stripe/configuration.rb
@@ -249,9 +250,9 @@ licenses:
249
250
  - MIT
250
251
  metadata:
251
252
  bug_tracker_uri: https://github.com/spree/spree_stripe/issues
252
- changelog_uri: https://github.com/spree/spree_stripe/releases/tag/v1.8.0
253
+ changelog_uri: https://github.com/spree/spree_stripe/releases/tag/v1.8.1
253
254
  documentation_uri: https://docs.spreecommerce.org/
254
- source_code_uri: https://github.com/spree/spree_stripe/tree/v1.8.0
255
+ source_code_uri: https://github.com/spree/spree_stripe/tree/v1.8.1
255
256
  rdoc_options: []
256
257
  require_paths:
257
258
  - lib
@@ -267,7 +268,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
267
268
  version: '0'
268
269
  requirements:
269
270
  - none
270
- rubygems_version: 4.0.2
271
+ rubygems_version: 4.0.3
271
272
  specification_version: 4
272
273
  summary: Official Spree Commerce Stripe payment gateway extension
273
274
  test_files: []