swell-js 5.9.3 → 5.10.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.
package/dist/payment.mjs CHANGED
@@ -677,25 +677,184 @@ async function createPaymentMethod(stripe, cardElement, cart) {
677
677
  billing_details: billingDetails,
678
678
  });
679
679
 
680
- return error
681
- ? { error }
682
- : {
683
- gateway: 'stripe',
684
- token: paymentMethod.id,
685
- last4: paymentMethod.card.last4,
686
- exp_month: paymentMethod.card.exp_month,
687
- exp_year: paymentMethod.card.exp_year,
688
- brand: paymentMethod.card.brand,
689
- display_brand: paymentMethod.card.display_brand,
690
- address_check: paymentMethod.card.checks.address_line1_check,
691
- cvc_check: paymentMethod.card.checks.cvc_check,
692
- zip_check: paymentMethod.card.checks.address_postal_code_check,
693
- };
680
+ return error ? { error } : mapCardPaymentMethod(paymentMethod);
694
681
  }
695
682
 
696
683
  /**
684
+ * Elements group options of the card Payment Element for the cart amount,
685
+ * matching the intent StripeCardPayment#_createIntent creates.
686
+ *
687
+ * @param {Cart} cart
688
+ * @returns {import('@stripe/stripe-js').StripeElementsOptionsMode}
689
+ */
690
+ function getCardElementsOptions(cart) {
691
+ const { capture_total, auth_total } = cart;
692
+ const currency = cart.currency || 'USD';
693
+ const stripeCurrency = currency.toLowerCase();
694
+
695
+ // the card is only stored when the amount is not chargeable, no intent is created
696
+ if (!isStripeChargeableAmount(capture_total, cart.currency)) {
697
+ return {
698
+ mode: 'setup',
699
+ currency: stripeCurrency,
700
+ paymentMethodTypes: ['card'],
701
+ };
702
+ }
703
+
704
+ return {
705
+ mode: 'payment',
706
+ amount: stripeAmountByCurrency(currency, capture_total + (auth_total || 0)),
707
+ currency: stripeCurrency,
708
+ captureMethod: 'manual',
709
+ setupFutureUsage: 'off_session',
710
+ paymentMethodTypes: ['card'],
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Elements group options of the iDEAL Payment Element,
716
+ * matching the intent StripeIDealPayment#_createIntent creates.
717
+ *
718
+ * @param {Cart} cart
719
+ * @returns {import('@stripe/stripe-js').StripeElementsOptionsMode}
720
+ */
721
+ function getIDealElementsOptions(cart) {
722
+ const { capture_total } = cart;
723
+ const currency = cart.currency || 'EUR';
724
+
725
+ return {
726
+ mode: 'payment',
727
+ amount: stripeAmountByCurrency(currency, capture_total),
728
+ currency: currency.toLowerCase(),
729
+ paymentMethodTypes: ['ideal'],
730
+ };
731
+ }
732
+
733
+ /**
734
+ * Creates an Elements group without an intent and mounts the Payment Element
735
+ * into `#${params.elementId || 'payment-element'}`.
736
+ *
737
+ * @param {Stripe} stripe
738
+ * @param {import('@stripe/stripe-js').StripeElementsOptionsMode} elementsOptions
739
+ * @param {object} params
740
+ * @returns {import('@stripe/stripe-js').StripeElements}
741
+ */
742
+ function createPaymentElements(stripe, elementsOptions, params) {
743
+ const elements = stripe.elements({
744
+ ...params.config,
745
+ ...elementsOptions,
746
+ // lets stripe.createPaymentMethod read the Payment Element
747
+ paymentMethodCreation: 'manual',
748
+ ...(params.appearance && { appearance: params.appearance }),
749
+ });
750
+
751
+ createElement('payment', elements, {
752
+ ...params,
753
+ options: {
754
+ layout: 'tabs',
755
+ fields: { billingDetails: 'never' },
756
+ // wallets are separate express buttons
757
+ wallets: { applePay: 'never', googlePay: 'never' },
758
+ ...params.options,
759
+ },
760
+ });
761
+
762
+ return elements;
763
+ }
764
+
765
+ /**
766
+ * Creates a PaymentMethod from the Payment Element.
767
+ * Brings the Elements amount up to date with the cart first.
768
+ *
697
769
  * @param {Stripe} stripe
698
- * @param {import('@stripe/stripe-js').StripeIdealBankElement} element
770
+ * @param {import('@stripe/stripe-js').StripeElements} elements
771
+ * @param {import('@stripe/stripe-js').StripeElementsOptionsMode} elementsOptions
772
+ * @param {Cart} cart
773
+ * @returns {Promise<import('@stripe/stripe-js').PaymentMethodResult>}
774
+ */
775
+ async function createPaymentMethodFromElements(
776
+ stripe,
777
+ elements,
778
+ elementsOptions,
779
+ cart,
780
+ ) {
781
+ // setup mode stores the card without an intent, there is no amount to update
782
+ if (elementsOptions.mode === 'payment') {
783
+ await elements.update(elementsOptions);
784
+ }
785
+
786
+ const { error } = await elements.submit();
787
+
788
+ if (error) {
789
+ return { error };
790
+ }
791
+
792
+ const { address, ...details } = getBillingDetails(cart);
793
+
794
+ // fields the Payment Element does not collect must all be passed, null when unknown
795
+ return stripe.createPaymentMethod({
796
+ elements,
797
+ params: {
798
+ billing_details: {
799
+ name: null,
800
+ email: null,
801
+ phone: null,
802
+ ...details,
803
+ address: {
804
+ line1: null,
805
+ line2: null,
806
+ city: null,
807
+ state: null,
808
+ postal_code: null,
809
+ country: null,
810
+ ...address,
811
+ },
812
+ },
813
+ },
814
+ });
815
+ }
816
+
817
+ /**
818
+ * @param {Stripe} stripe
819
+ * @param {import('@stripe/stripe-js').StripeElements} elements
820
+ * @param {Cart} cart
821
+ */
822
+ async function createCardPaymentMethodFromElements(
823
+ stripe,
824
+ elements,
825
+ cart,
826
+ ) {
827
+ const { paymentMethod, error } = await createPaymentMethodFromElements(
828
+ stripe,
829
+ elements,
830
+ getCardElementsOptions(cart),
831
+ cart,
832
+ );
833
+
834
+ return error ? { error } : mapCardPaymentMethod(paymentMethod);
835
+ }
836
+
837
+ /**
838
+ * @param {import('@stripe/stripe-js').PaymentMethod} paymentMethod
839
+ */
840
+ function mapCardPaymentMethod(paymentMethod) {
841
+ return {
842
+ gateway: 'stripe',
843
+ token: paymentMethod.id,
844
+ last4: paymentMethod.card.last4,
845
+ exp_month: paymentMethod.card.exp_month,
846
+ exp_year: paymentMethod.card.exp_year,
847
+ brand: paymentMethod.card.brand,
848
+ display_brand: paymentMethod.card.display_brand,
849
+ address_check: paymentMethod.card.checks.address_line1_check,
850
+ cvc_check: paymentMethod.card.checks.cvc_check,
851
+ zip_check: paymentMethod.card.checks.address_postal_code_check,
852
+ };
853
+ }
854
+
855
+ /**
856
+ * @param {Stripe} stripe
857
+ * @param {import('@stripe/stripe-js').StripeIdealBankElement | {}} element `{}` when there is no idealBank element
699
858
  * @param {Cart} cart
700
859
  */
701
860
  async function createIDealPaymentMethod(stripe, element, cart) {
@@ -883,6 +1042,29 @@ function isStripeChargeableAmount(amount, currency) {
883
1042
  return !minAmount || amount >= minAmount;
884
1043
  }
885
1044
 
1045
+ // The cart keeps what checkout v1 keeps, so the next tokenize can retrieve and reuse
1046
+ // the intent; the vault cancels the replaced ones when the order is paid
1047
+ const cartIntent = (cart, intent) => {
1048
+ const exIntent = cart.billing?.intent?.stripe;
1049
+
1050
+ return {
1051
+ id: intent.id,
1052
+ client_secret: intent.client_secret,
1053
+ amount: intent.amount,
1054
+ currency: intent.currency,
1055
+ ...(Boolean(cart.auth_total) && { auth_amount: cart.auth_total }),
1056
+ ...(exIntent?.id &&
1057
+ exIntent.id !== intent.id && {
1058
+ canceled: [...(exIntent.canceled || []), exIntent.id],
1059
+ }),
1060
+ };
1061
+ };
1062
+
1063
+ const intentAmount = ({ currency, capture_total, auth_total }) =>
1064
+ stripeAmountByCurrency(currency, capture_total + auth_total);
1065
+
1066
+ const intentCurrency = ({ currency }) => (currency || 'USD').toLowerCase();
1067
+
886
1068
  /** @typedef {import('@stripe/stripe-js').Stripe} Stripe */
887
1069
  /** @typedef {import('@stripe/stripe-js').StripeCardElement} StripeCardElement */
888
1070
  /** @typedef {import('@stripe/stripe-js').StripeCardNumberElement} StripeCardNumberElement */
@@ -926,9 +1108,30 @@ class StripeCardPayment extends Payment {
926
1108
  StripeCardPayment.stripeElement = stripeElement;
927
1109
  }
928
1110
 
929
- async createElements() {
1111
+ /** @returns {import('@stripe/stripe-js').StripeElements} */
1112
+ get stripeElements() {
1113
+ return StripeCardPayment.stripeElements;
1114
+ }
1115
+
1116
+ /** @param {import('@stripe/stripe-js').StripeElements} stripeElements */
1117
+ set stripeElements(stripeElements) {
1118
+ StripeCardPayment.stripeElements = stripeElements;
1119
+ }
1120
+
1121
+ /** @param {import('../../../types').Cart} cart */
1122
+ async createElements(cart) {
930
1123
  await this.loadScripts(this.scripts);
931
1124
 
1125
+ if (this.params.paymentElement) {
1126
+ this.stripeElements = createPaymentElements(
1127
+ this.stripe,
1128
+ getCardElementsOptions(cart),
1129
+ this.params,
1130
+ );
1131
+
1132
+ return;
1133
+ }
1134
+
932
1135
  const elements = this.stripe.elements(this.params.config);
933
1136
 
934
1137
  if (this.params.separateElements) {
@@ -941,18 +1144,26 @@ class StripeCardPayment extends Payment {
941
1144
  }
942
1145
 
943
1146
  async tokenize() {
944
- if (!this.stripeElement) {
1147
+ const { paymentElement, paymentMethod: token } = this.params;
1148
+
1149
+ if (token) {
1150
+ return this._authorizePaymentMethod(token);
1151
+ }
1152
+
1153
+ if (!(paymentElement ? this.stripeElements : this.stripeElement)) {
945
1154
  throw new Error('Stripe payment element is not defined');
946
1155
  }
947
1156
 
948
1157
  await this.loadScripts(this.scripts);
949
1158
 
950
1159
  const cart = await this.getCart();
951
- const paymentMethod = await createPaymentMethod(
952
- this.stripe,
953
- this.stripeElement,
954
- cart,
955
- );
1160
+ const paymentMethod = paymentElement
1161
+ ? await createCardPaymentMethodFromElements(
1162
+ this.stripe,
1163
+ this.stripeElements,
1164
+ cart,
1165
+ )
1166
+ : await createPaymentMethod(this.stripe, this.stripeElement, cart);
956
1167
 
957
1168
  if (paymentMethod.error) {
958
1169
  throw new Error(paymentMethod.error.message);
@@ -962,8 +1173,12 @@ class StripeCardPayment extends Payment {
962
1173
  paymentMethod.test = true;
963
1174
  }
964
1175
 
965
- // should save payment method data when payment amount is not chargeable
966
- if (!isStripeChargeableAmount(cart.capture_total, cart.currency)) {
1176
+ // should save payment method data when payment amount is not chargeable,
1177
+ // or when the host collects the card without authorizing it now
1178
+ if (
1179
+ !isStripeChargeableAmount(cart.capture_total, cart.currency) ||
1180
+ this.params.createIntent === false
1181
+ ) {
967
1182
  await this.updateCart({
968
1183
  billing: {
969
1184
  method: 'card',
@@ -974,60 +1189,129 @@ class StripeCardPayment extends Payment {
974
1189
  return this.onSuccess();
975
1190
  }
976
1191
 
977
- const intent = await this._createIntent(cart, paymentMethod);
1192
+ const intent = await this._authorizeIntent(cart, paymentMethod.token);
978
1193
 
979
1194
  await this.updateCart({
980
1195
  billing: {
981
1196
  method: 'card',
982
1197
  card: paymentMethod,
983
- intent: {
984
- stripe: {
985
- id: intent.id,
986
- ...(Boolean(cart.auth_total) && {
987
- auth_amount: cart.auth_total,
988
- }),
989
- },
990
- },
1198
+ intent: { stripe: cartIntent(cart, intent) },
991
1199
  },
992
1200
  });
993
1201
 
994
1202
  this.onSuccess();
995
1203
  }
996
1204
 
997
- async authenticate(payment) {
998
- const { transaction_id: id, card: { token } = {} } = payment;
1205
+ // A card the shopper already has (a saved card, a wallet's payment method) gets
1206
+ // the intent and 3-D Secure of a new one; other cards are authorized with the order
1207
+ async _authorizePaymentMethod(token) {
1208
+ const cart = await this.getCart();
999
1209
 
1000
- const intent = await this.updateIntent({
1001
- gateway: 'stripe',
1002
- intent: { id, payment_method: token },
1003
- });
1210
+ if (
1211
+ token.startsWith('pm_') &&
1212
+ isStripeChargeableAmount(cart.capture_total, cart.currency) &&
1213
+ this.params.createIntent !== false
1214
+ ) {
1215
+ await this.loadScripts(this.scripts);
1216
+
1217
+ const intent = await this._authorizeIntent(cart, token);
1004
1218
 
1005
- if (intent.error) {
1006
- throw new Error(intent.error.message);
1219
+ if (intent.id !== cart.billing?.intent?.stripe?.id) {
1220
+ await this.updateCart({
1221
+ billing: {
1222
+ method: 'card',
1223
+ intent: { stripe: cartIntent(cart, intent) },
1224
+ },
1225
+ });
1226
+ }
1007
1227
  }
1008
1228
 
1009
- await this.loadScripts(this.scripts);
1229
+ this.onSuccess();
1230
+ }
1010
1231
 
1232
+ // Reuses the cart's intent while Stripe still has it for this card and amount
1233
+ async _authorizeIntent(cart, token) {
1234
+ const exIntent = cart.billing?.intent?.stripe;
1235
+ const intent =
1236
+ (exIntent?.client_secret &&
1237
+ (await this._retrieveIntent(cart, exIntent, token))) ||
1238
+ (await this._createIntent(cart, token));
1239
+
1240
+ switch (intent.status) {
1241
+ case 'requires_capture':
1242
+ break;
1243
+
1244
+ case 'requires_confirmation':
1245
+ await this._confirmCardPayment(intent);
1246
+ break;
1247
+
1248
+ case 'requires_action':
1249
+ case 'requires_source_action': {
1250
+ const actionResult = await this.stripe.handleCardAction(
1251
+ intent.client_secret,
1252
+ );
1253
+
1254
+ if (actionResult.error) {
1255
+ throw new Error(actionResult.error.message);
1256
+ }
1257
+
1258
+ break;
1259
+ }
1260
+
1261
+ default:
1262
+ throw new Error(`Unsupported intent status: ${intent.status}`);
1263
+ }
1264
+
1265
+ return intent;
1266
+ }
1267
+
1268
+ async _retrieveIntent(cart, exIntent, token) {
1269
+ const { paymentIntent: intent, error } =
1270
+ await this.stripe.retrievePaymentIntent(exIntent.client_secret);
1271
+
1272
+ const matches =
1273
+ !error &&
1274
+ intent.amount === intentAmount(cart) &&
1275
+ intent.currency === intentCurrency(cart) &&
1276
+ intent.payment_method === token &&
1277
+ !['canceled', 'requires_source', 'requires_payment_method'].includes(
1278
+ intent.status,
1279
+ );
1280
+
1281
+ return matches ? intent : null;
1282
+ }
1283
+
1284
+ async authenticate(payment) {
1285
+ const { transaction_id: id, card: { token } = {} } = payment;
1286
+
1287
+ // The payment is re-queried whatever happens here: an intent the shopper
1288
+ // already confirmed can't be updated, and the payment must still learn it
1011
1289
  try {
1012
- const result = await this._confirmCardPayment(intent);
1290
+ const intent = await this.updateIntent({
1291
+ gateway: 'stripe',
1292
+ intent: { id, payment_method: token },
1293
+ });
1013
1294
 
1014
- return result;
1295
+ if (intent.error) {
1296
+ throw new Error(intent.error.message);
1297
+ }
1298
+
1299
+ await this.loadScripts(this.scripts);
1300
+
1301
+ return await this._confirmCardPayment(intent);
1015
1302
  } finally {
1016
1303
  await this.resetAsyncPayment(payment.id);
1017
1304
  }
1018
1305
  }
1019
1306
 
1020
- async _createIntent(cart, paymentMethod) {
1021
- const { account, currency, capture_total, auth_total } = cart;
1022
- const stripeCustomer = account && account.stripe_customer;
1023
- const stripeCurrency = (currency || 'USD').toLowerCase();
1024
- const amount = stripeAmountByCurrency(currency, capture_total + auth_total);
1307
+ async _createIntent(cart, token) {
1308
+ const stripeCustomer = cart.account && cart.account.stripe_customer;
1025
1309
  const intent = await this.createIntent({
1026
1310
  gateway: 'stripe',
1027
1311
  intent: {
1028
- amount,
1029
- currency: stripeCurrency,
1030
- payment_method: paymentMethod.token,
1312
+ amount: intentAmount(cart),
1313
+ currency: intentCurrency(cart),
1314
+ payment_method: token,
1031
1315
  capture_method: 'manual',
1032
1316
  setup_future_usage: 'off_session',
1033
1317
  ...(stripeCustomer ? { customer: stripeCustomer } : {}),
@@ -1038,20 +1322,6 @@ class StripeCardPayment extends Payment {
1038
1322
  throw new Error('Stripe payment intent is not defined');
1039
1323
  }
1040
1324
 
1041
- switch (intent.status) {
1042
- case 'requires_capture':
1043
- case 'requires_confirmation':
1044
- break;
1045
-
1046
- default:
1047
- throw new Error(`Unsupported intent status: ${intent.status}`);
1048
- }
1049
-
1050
- // Confirm the payment intent
1051
- if (intent.status === 'requires_confirmation') {
1052
- await this._confirmCardPayment(intent);
1053
- }
1054
-
1055
1325
  return intent;
1056
1326
  }
1057
1327
 
@@ -1119,24 +1389,58 @@ class StripeIDealPayment extends Payment {
1119
1389
  StripeIDealPayment.stripeElement = stripeElement;
1120
1390
  }
1121
1391
 
1122
- async createElements() {
1392
+ /** @returns {import('@stripe/stripe-js').StripeElements} */
1393
+ get stripeElements() {
1394
+ return StripeIDealPayment.stripeElements;
1395
+ }
1396
+
1397
+ /** @param {import('@stripe/stripe-js').StripeElements} stripeElements */
1398
+ set stripeElements(stripeElements) {
1399
+ StripeIDealPayment.stripeElements = stripeElements;
1400
+ }
1401
+
1402
+ /** @param {import('../../../types').Cart} cart */
1403
+ async createElements(cart) {
1123
1404
  await this.loadScripts(this.scripts);
1124
1405
 
1406
+ if (this.params.paymentElement) {
1407
+ this.stripeElements = createPaymentElements(
1408
+ this.stripe,
1409
+ getIDealElementsOptions(cart),
1410
+ this.params,
1411
+ );
1412
+
1413
+ return;
1414
+ }
1415
+
1125
1416
  const elements = this.stripe.elements(this.params.config);
1126
1417
 
1127
1418
  this.stripeElement = createElement('idealBank', elements, this.params);
1128
1419
  }
1129
1420
 
1130
1421
  async tokenize() {
1131
- if (!this.stripeElement) {
1422
+ const { paymentElement } = this.params;
1423
+
1424
+ if (paymentElement && !this.stripeElements) {
1132
1425
  throw new Error('Stripe payment element is not defined');
1133
1426
  }
1134
1427
 
1135
1428
  await this.loadScripts(this.scripts);
1136
1429
 
1137
1430
  const cart = await this.getCart();
1138
- const { paymentMethod, error: paymentMethodError } =
1139
- await createIDealPaymentMethod(this.stripe, this.stripeElement, cart);
1431
+ const { paymentMethod, error: paymentMethodError } = paymentElement
1432
+ ? await createPaymentMethodFromElements(
1433
+ this.stripe,
1434
+ this.stripeElements,
1435
+ getIDealElementsOptions(cart),
1436
+ cart,
1437
+ )
1438
+ : // without an idealBank element the shopper picks the bank on the iDEAL page
1439
+ await createIDealPaymentMethod(
1440
+ this.stripe,
1441
+ this.stripeElement || {},
1442
+ cart,
1443
+ );
1140
1444
 
1141
1445
  if (paymentMethodError) {
1142
1446
  throw new Error(paymentMethodError.message);
@@ -1147,6 +1451,38 @@ class StripeIDealPayment extends Payment {
1147
1451
  await this.stripe.handleCardAction(intent.client_secret);
1148
1452
  }
1149
1453
 
1454
+ async handleRedirect(queryParams) {
1455
+ const { redirect_status, payment_intent_client_secret } = queryParams;
1456
+
1457
+ if (redirect_status !== 'succeeded') {
1458
+ throw new UnableAuthenticatePaymentMethodError();
1459
+ }
1460
+
1461
+ await this.loadScripts(this.scripts);
1462
+
1463
+ const { paymentIntent, error } = await this.stripe.retrievePaymentIntent(
1464
+ payment_intent_client_secret,
1465
+ );
1466
+
1467
+ if (error) {
1468
+ throw new Error(error.message);
1469
+ }
1470
+
1471
+ if (
1472
+ !['succeeded', 'requires_capture', 'processing'].includes(
1473
+ paymentIntent.status,
1474
+ )
1475
+ ) {
1476
+ throw new Error(
1477
+ paymentIntent.last_payment_error?.message ||
1478
+ `Unsupported intent status (${paymentIntent.status})`,
1479
+ );
1480
+ }
1481
+
1482
+ // the cart's billing was written in `tokenize`
1483
+ this.onSuccess();
1484
+ }
1485
+
1150
1486
  /**
1151
1487
  * @param {object} cart
1152
1488
  * @param {import('@stripe/stripe-js').PaymentMethod} paymentMethod
@@ -1164,7 +1500,8 @@ class StripeIDealPayment extends Payment {
1164
1500
  payment_method_types: 'ideal',
1165
1501
  confirmation_method: 'manual',
1166
1502
  confirm: true,
1167
- return_url: window.location.href,
1503
+ // `gateway` lets `handleRedirect` run on return; Stripe appends the intent and `redirect_status`
1504
+ return_url: getReturnUrl(),
1168
1505
  },
1169
1506
  });
1170
1507
 
@@ -1196,6 +1533,14 @@ class StripeIDealPayment extends Payment {
1196
1533
  }
1197
1534
  }
1198
1535
 
1536
+ function getReturnUrl() {
1537
+ const url = new URL(window.location.href);
1538
+
1539
+ url.searchParams.set('gateway', 'stripe');
1540
+
1541
+ return url.toString();
1542
+ }
1543
+
1199
1544
  /** @typedef {import('@stripe/stripe-js').Stripe} Stripe */
1200
1545
 
1201
1546
  class StripeBancontactPayment extends Payment {
@@ -1515,6 +1860,7 @@ class StripeGooglePayment extends Payment {
1515
1860
  async _onPaymentMethod(event) {
1516
1861
  const {
1517
1862
  payerEmail,
1863
+ payerName,
1518
1864
  paymentMethod: { id: paymentMethod, card, billing_details },
1519
1865
  shippingAddress,
1520
1866
  shippingOption,
@@ -1533,6 +1879,8 @@ class StripeGooglePayment extends Payment {
1533
1879
  }),
1534
1880
  billing: {
1535
1881
  ...this._mapBillingAddress(billing_details),
1882
+ // a wallet may leave the card's address without a name; the payer's, when asked for, fills it
1883
+ name: billing_details?.name || payerName,
1536
1884
  method: 'card',
1537
1885
  card: {
1538
1886
  gateway: 'stripe',
@@ -1771,6 +2119,7 @@ class StripeApplePayment extends Payment {
1771
2119
  async _onPaymentMethod(event) {
1772
2120
  const {
1773
2121
  payerEmail,
2122
+ payerName,
1774
2123
  paymentMethod: { id: paymentMethod, card, billing_details },
1775
2124
  shippingAddress,
1776
2125
  shippingOption,
@@ -1789,6 +2138,8 @@ class StripeApplePayment extends Payment {
1789
2138
  }),
1790
2139
  billing: {
1791
2140
  ...this._mapBillingAddress(billing_details),
2141
+ // a wallet may leave the card's address without a name; the payer's, when asked for, fills it
2142
+ name: billing_details?.name || payerName,
1792
2143
  method: 'card',
1793
2144
  card: {
1794
2145
  gateway: 'stripe',
@@ -4219,6 +4570,238 @@ class QuickpayCardPayment extends Payment {
4219
4570
  }
4220
4571
  }
4221
4572
 
4573
+ // Saferpay payment-page brands per Swell method id; a list is joined because the
4574
+ // vault rejects arrays in `PaymentMethods`
4575
+ const PAYMENT_METHODS = {
4576
+ card: [
4577
+ 'AMEX',
4578
+ 'DINERS',
4579
+ 'MAESTRO',
4580
+ 'JCB',
4581
+ 'VISA',
4582
+ 'BANCONTACT',
4583
+ 'MASTERCARD',
4584
+ 'SAFERPAYTEST',
4585
+ 'UNIONPAY',
4586
+ ].join(','),
4587
+ twint: 'TWINT',
4588
+ };
4589
+
4590
+ /**
4591
+ * @param {string} method 'card' or 'twint'
4592
+ * @returns {string}
4593
+ */
4594
+ function getSaferpayPaymentMethods(method) {
4595
+ return PAYMENT_METHODS[method];
4596
+ }
4597
+
4598
+ /**
4599
+ * The Saferpay payment page request for a cart.
4600
+ *
4601
+ * @param {object} cart
4602
+ * @param {string} method 'card' or 'twint'
4603
+ * @returns {object}
4604
+ */
4605
+ function getSaferpayPaymentPageData(cart, method) {
4606
+ const currency = (cart.currency || 'USD').toUpperCase();
4607
+ const returnUrl = window.location.origin + window.location.pathname;
4608
+
4609
+ return {
4610
+ Payment: {
4611
+ Amount: {
4612
+ Value: amountInCents(currency, cart.grand_total || 0),
4613
+ CurrencyCode: currency,
4614
+ },
4615
+ Description: 'Saferpay payment powered by Swell',
4616
+ },
4617
+ PaymentMethods: getSaferpayPaymentMethods(method),
4618
+ ReturnUrls: {
4619
+ Success: `${returnUrl}?gateway=saferpay&redirect_status=succeeded`,
4620
+ Fail: `${returnUrl}?gateway=saferpay&redirect_status=failed`,
4621
+ Abort: `${returnUrl}?gateway=saferpay&redirect_status=canceled`,
4622
+ },
4623
+ };
4624
+ }
4625
+
4626
+ /**
4627
+ * @param {object} state the Saferpay transaction state
4628
+ * @returns {boolean}
4629
+ */
4630
+ function isSaferpayCardPayment(state) {
4631
+ const brand = state?.PaymentMeans?.Brand;
4632
+
4633
+ return Boolean(
4634
+ state?.RegistrationResult?.Success &&
4635
+ brand &&
4636
+ PAYMENT_METHODS.card.split(',').includes(brand.PaymentMethod),
4637
+ );
4638
+ }
4639
+
4640
+ /**
4641
+ * @param {object} state the Saferpay transaction state
4642
+ * @returns {boolean}
4643
+ */
4644
+ function isSaferpayTwintPayment(state) {
4645
+ return state?.PaymentMeans?.Brand?.PaymentMethod === 'TWINT';
4646
+ }
4647
+
4648
+ /**
4649
+ * The card stored on the cart's billing from a Saferpay card state.
4650
+ *
4651
+ * @param {object} state
4652
+ * @returns {object}
4653
+ */
4654
+ function getSaferpayCard(state) {
4655
+ const {
4656
+ RegistrationResult: {
4657
+ Alias: { Id: token },
4658
+ },
4659
+ PaymentMeans: {
4660
+ Card: { ExpMonth: exp_month, ExpYear: exp_year, MaskedNumber },
4661
+ Brand: { Name: brand },
4662
+ },
4663
+ } = state;
4664
+
4665
+ return {
4666
+ token,
4667
+ exp_month,
4668
+ exp_year,
4669
+ brand,
4670
+ last4: MaskedNumber.slice(-4),
4671
+ gateway: 'saferpay',
4672
+ };
4673
+ }
4674
+
4675
+ /**
4676
+ * @param {object} state
4677
+ * @returns {string | undefined}
4678
+ */
4679
+ function getSaferpayTransactionId(state) {
4680
+ return state?.Transaction?.Id;
4681
+ }
4682
+
4683
+ /**
4684
+ * Saferpay payment page: `tokenize` sends the shopper to Saferpay, and
4685
+ * `handleRedirect` turns the returned transaction into the cart's billing.
4686
+ * The card and TWINT classes share it; each one handles only the redirect of
4687
+ * the method the cart was tokenized with, so a host can pass both.
4688
+ */
4689
+ class SaferpayPayment extends Payment {
4690
+ constructor(api, options, params, methods, methodId) {
4691
+ super(api, options, params, methods[methodId]);
4692
+ this.methodId = methodId;
4693
+ this.cardMethod = methods.card;
4694
+ }
4695
+
4696
+ async tokenize() {
4697
+ const cart = await this.getCart();
4698
+
4699
+ // a card Saferpay already registered on this cart pays again without a new page visit
4700
+ if (
4701
+ this.methodId === 'card' &&
4702
+ cart.billing?.card?.gateway === 'saferpay' &&
4703
+ !this.params?.replace
4704
+ ) {
4705
+ return this.onSuccess();
4706
+ }
4707
+
4708
+ const intent = await this.createIntent({
4709
+ gateway: 'saferpay',
4710
+ intent: getSaferpayPaymentPageData(cart, this.methodId),
4711
+ });
4712
+
4713
+ if (!intent) {
4714
+ throw new Error('Saferpay payment page is not defined');
4715
+ }
4716
+
4717
+ await this.updateCart({
4718
+ billing: {
4719
+ method: this.methodId,
4720
+ intent: {
4721
+ saferpay: {
4722
+ token: intent.token,
4723
+ },
4724
+ },
4725
+ },
4726
+ });
4727
+
4728
+ window.location.replace(intent.redirect_url);
4729
+ }
4730
+
4731
+ async handleRedirect(queryParams) {
4732
+ const { redirect_status: status } = queryParams;
4733
+ const cart = await this.getCart();
4734
+
4735
+ // another Saferpay method of this host was tokenized: its instance answers
4736
+ if (cart.billing?.method !== this.methodId) {
4737
+ return;
4738
+ }
4739
+
4740
+ switch (status) {
4741
+ case 'succeeded':
4742
+ return this._handleSuccessfulRedirect(cart);
4743
+ case 'canceled':
4744
+ return this.onCancel();
4745
+ case 'failed':
4746
+ throw new UnableAuthenticatePaymentMethodError();
4747
+ default:
4748
+ throw new Error(`Unknown redirect status: ${status}.`);
4749
+ }
4750
+ }
4751
+
4752
+ async _handleSuccessfulRedirect(cart) {
4753
+ const token = cart.billing?.intent?.saferpay?.token;
4754
+
4755
+ if (!token) {
4756
+ throw new Error('Saferpay transaction token is not defined');
4757
+ }
4758
+
4759
+ const intent = await this.updateIntent({
4760
+ gateway: 'saferpay',
4761
+ intent: { token },
4762
+ });
4763
+
4764
+ if (!intent) {
4765
+ throw new Error('Saferpay transaction is not defined');
4766
+ }
4767
+
4768
+ const { state } = intent;
4769
+ const transaction = { transaction_id: getSaferpayTransactionId(state) };
4770
+
4771
+ if (isSaferpayCardPayment(state)) {
4772
+ const card = getSaferpayCard(state);
4773
+
4774
+ if (!isLiveMode(this.cardMethod?.mode)) {
4775
+ card.test = true;
4776
+ }
4777
+
4778
+ await this.updateCart({
4779
+ billing: { method: 'card', card, intent: { saferpay: transaction } },
4780
+ });
4781
+ } else if (isSaferpayTwintPayment(state)) {
4782
+ await this.updateCart({
4783
+ billing: { method: 'twint', intent: { saferpay: transaction } },
4784
+ });
4785
+ } else {
4786
+ throw new Error('Payment method not supported by this gateway.');
4787
+ }
4788
+
4789
+ this.onSuccess();
4790
+ }
4791
+ }
4792
+
4793
+ class SaferpayCardPayment extends SaferpayPayment {
4794
+ constructor(api, options, params, methods) {
4795
+ super(api, options, params, methods, 'card');
4796
+ }
4797
+ }
4798
+
4799
+ class SaferpayTwintPayment extends SaferpayPayment {
4800
+ constructor(api, options, params, methods) {
4801
+ super(api, options, params, methods, 'twint');
4802
+ }
4803
+ }
4804
+
4222
4805
  let convesioSettings = null;
4223
4806
 
4224
4807
  function getBaseConvesioApiUrl(mode) {
@@ -5899,6 +6482,33 @@ class PaymentController {
5899
6482
  return this.api.request('get', '/payments', id);
5900
6483
  }
5901
6484
 
6485
+ /**
6486
+ * Sends the shopper to the gateway when a submitted order waits for an
6487
+ * asynchronous payment; resolves `true` after starting the redirect.
6488
+ *
6489
+ * @param {object} order the result of `cart.submitOrder()`
6490
+ * @returns {Promise<boolean>}
6491
+ */
6492
+ async handleAsyncRedirect(order) {
6493
+ const paymentId =
6494
+ order?.authorized_payment_id ?? order?.authorizedPaymentId;
6495
+
6496
+ if (order?.status !== 'payment_pending' || !paymentId) {
6497
+ return false;
6498
+ }
6499
+
6500
+ const payment = await this.get(paymentId);
6501
+ const url = payment?.async_data?.url ?? payment?.asyncData?.url;
6502
+
6503
+ if (!payment || payment.captured || !url) {
6504
+ return false;
6505
+ }
6506
+
6507
+ window.location.replace(url);
6508
+
6509
+ return true;
6510
+ }
6511
+
5902
6512
  async methods() {
5903
6513
  if (this.methodSettings) {
5904
6514
  return this.methodSettings;
@@ -5916,7 +6526,7 @@ class PaymentController {
5916
6526
  throw new Error('Payment element parameters are not provided');
5917
6527
  }
5918
6528
 
5919
- const paymentInstances = await this._createPaymentInstances();
6529
+ const paymentInstances = await this._createPaymentInstances(params);
5920
6530
  const cart = await this.payment.getCart();
5921
6531
 
5922
6532
  await this._performPaymentAction(
@@ -5935,7 +6545,7 @@ class PaymentController {
5935
6545
  throw new Error('Tokenization parameters are not provided');
5936
6546
  }
5937
6547
 
5938
- const paymentInstances = await this._createPaymentInstances();
6548
+ const paymentInstances = await this._createPaymentInstances(params);
5939
6549
 
5940
6550
  await this._performPaymentAction(paymentInstances, 'tokenize');
5941
6551
  }
@@ -5955,7 +6565,7 @@ class PaymentController {
5955
6565
 
5956
6566
  removeUrlParams();
5957
6567
 
5958
- const paymentInstances = await this._createPaymentInstances();
6568
+ const paymentInstances = await this._createPaymentInstances(params);
5959
6569
 
5960
6570
  await this._performPaymentAction(
5961
6571
  paymentInstances,
@@ -6048,9 +6658,10 @@ class PaymentController {
6048
6658
  /**
6049
6659
  * @returns {Promise<Payment[]>}
6050
6660
  */
6051
- async _createPaymentInstances() {
6661
+ // Takes the caller's params: this.params can change while the methods load (two createElements at once)
6662
+ async _createPaymentInstances(callParams) {
6052
6663
  const paymentMethods = await this._getPaymentMethods();
6053
- const params = adjustParams(this.params);
6664
+ const params = adjustParams(callParams);
6054
6665
 
6055
6666
  return Object.entries(params).reduce((acc, [method, params]) => {
6056
6667
  const methodSettings = paymentMethods[method];
@@ -6148,6 +6759,8 @@ class PaymentController {
6148
6759
  return this._getAmazonPaymentClass(gateway);
6149
6760
  case 'sezzle':
6150
6761
  return this._getSezzlePaymentClass(gateway);
6762
+ case 'twint':
6763
+ return this._getTwintPaymentClass(gateway);
6151
6764
  default:
6152
6765
  return null;
6153
6766
  }
@@ -6161,6 +6774,17 @@ class PaymentController {
6161
6774
  return QuickpayCardPayment;
6162
6775
  case 'convesiopay':
6163
6776
  return ConvesioCardPayment;
6777
+ case 'saferpay':
6778
+ return SaferpayCardPayment;
6779
+ default:
6780
+ return null;
6781
+ }
6782
+ }
6783
+
6784
+ _getTwintPaymentClass(gateway) {
6785
+ switch (gateway) {
6786
+ case 'saferpay':
6787
+ return SaferpayTwintPayment;
6164
6788
  default:
6165
6789
  return null;
6166
6790
  }