diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java index c453f05..d6c7777 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripePaymentPluginApi.java @@ -335,15 +335,23 @@ public void addPaymentMethod(final UUID kbAccountId, final UUID kbPaymentMethodI final Map additionalDataMap; final String stripeId; + final String customerId; final String existingCustomerId = getCustomerIdNoException(kbAccountId, context); if (paymentMethodIdInStripe != null) { if ("payment_method".equals(objectType)) { try { final PaymentMethod stripePaymentMethod = PaymentMethod.retrieve(paymentMethodIdInStripe, requestOptions); - additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripePaymentMethod); - ImmutableMap params = ImmutableMap.of("payment_method", stripePaymentMethod.getId()); - createStripeCustomer(kbAccountId, existingCustomerId, params, requestOptions, allProperties, context); - stripeId = stripePaymentMethod.getId(); + final PaymentMethod paymentMethodForAdditionalData; + if (existingCustomerId == null) { + ImmutableMap params = ImmutableMap.of("payment_method", stripePaymentMethod.getId()); + createStripeCustomer(kbAccountId, null, params, requestOptions, allProperties, context); + paymentMethodForAdditionalData = stripePaymentMethod; + } else { + ImmutableMap attachParams = ImmutableMap.of("customer", existingCustomerId); + paymentMethodForAdditionalData = stripePaymentMethod.attach(attachParams, requestOptions); + } + additionalDataMap = StripePluginProperties.toAdditionalDataMap(paymentMethodForAdditionalData); + stripeId = paymentMethodForAdditionalData.getId(); } catch (final StripeException e) { throw new PaymentPluginApiException("Error calling Stripe while adding payment method", e); } @@ -351,20 +359,46 @@ public void addPaymentMethod(final UUID kbAccountId, final UUID kbPaymentMethodI try { final Token stripeToken = Token.retrieve(paymentMethodIdInStripe, requestOptions); additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripeToken); - ImmutableMap params = ImmutableMap.of("source", stripeToken.getId()); - String customerId = createStripeCustomer(kbAccountId, existingCustomerId, params, requestOptions, allProperties, context); - stripeId = retrievePaymentMethod(customerId, existingCustomerId, getTokenInnerId(stripeToken), requestOptions); - } catch (final StripeException e) { + + if (existingCustomerId == null) { + ImmutableMap params = ImmutableMap.of("source", stripeToken.getId()); + customerId = createStripeCustomer(kbAccountId, null, params, requestOptions, allProperties, context); + stripeId = retrievePaymentMethod(customerId, null, getTokenInnerId(stripeToken), requestOptions); + } else { + final Customer customer = Customer.retrieve(existingCustomerId, expandSourcesParams, requestOptions); + final Map attachParams = new HashMap<>(); + attachParams.put("source", stripeToken.getId()); + final PaymentSource attachedSource = customer.getSources().create(attachParams, requestOptions); + stripeId = attachedSource.getId(); + + if (setDefault) { + final Map defaultParams = new HashMap<>(); + defaultParams.put("default_source", stripeId); + customer.update(defaultParams, requestOptions); + } + } + } catch (final StripeException e) { throw new PaymentPluginApiException("Error calling Stripe while adding payment method", e); } } else if ("source".equals(objectType)) { try { - // The Stripe sourceId must be passed as the PaymentMethodPlugin#getExternalPaymentMethodId final Source stripeSource = Source.retrieve(paymentMethodIdInStripe, requestOptions); - additionalDataMap = StripePluginProperties.toAdditionalDataMap(stripeSource); - ImmutableMap params = ImmutableMap.of("source", stripeSource.getId()); - createStripeCustomer(kbAccountId, existingCustomerId, params, requestOptions, allProperties, context); - stripeId = stripeSource.getId(); + final PaymentSource sourceForAdditionalData; + + if (existingCustomerId == null) { + ImmutableMap params = ImmutableMap.of("source", stripeSource.getId()); + createStripeCustomer(kbAccountId, null, params, requestOptions, allProperties, context); + sourceForAdditionalData = stripeSource; + } else { + final Customer customer = Customer.retrieve(existingCustomerId, expandSourcesParams, requestOptions); + final Map attachParams = new HashMap<>(); + attachParams.put("source", stripeSource.getId()); + sourceForAdditionalData = customer.getSources().create(attachParams, requestOptions); + } + + additionalDataMap = StripePluginProperties.toAdditionalDataMap(sourceForAdditionalData); + stripeId = sourceForAdditionalData.getId(); + } catch (final StripeException e) { throw new PaymentPluginApiException("Error calling Stripe while adding payment method", e); } diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java index 308bb47..f1c5369 100644 --- a/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripePaymentPluginApi.java @@ -317,6 +317,139 @@ public void testVerifySyncOfPaymentMethods() throws PaymentPluginApiException, S context); assertEquals(((Map) PluginProperties.toMap(paymentMethodDetail.getProperties()).get("metadata")).get("testing"), metadata.get("testing")); } + + @Test(groups = "integration") + public void testAddSecondPaymentMethodToExistingCustomer() throws PaymentPluginApiException, StripeException { + final UUID kbAccountId = account.getId(); + + // 1. Create the customer and the first payment method (This sets existingCustomerId) + createStripeCustomerWithCreditCardAndSyncPaymentMethod(); + final String existingStripeCustomerId = customer.getId(); + + // Verify we start with exactly 1 payment method + List paymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(paymentMethods.size(), 1); + + // 2. Create a SECOND payment method in Stripe (e.g., a Mastercard) + // At this point, it is just a floating payment method not attached to any customer + final RequestOptions options = stripePaymentPluginApi.buildRequestOptions(context); + Map pmParams = new HashMap<>(); + pmParams.put("type", "card"); + pmParams.put("card", ImmutableMap.of("token", "tok_mastercard")); // Stripe testing token + PaymentMethod secondStripePaymentMethod = PaymentMethod.create(pmParams, options); + + // 3. Add the second payment method to the existing Kill Bill account + // It should detect the existing customer and call .attach() + final UUID secondKbPaymentMethodId = UUID.randomUUID(); + stripePaymentPluginApi.addPaymentMethod(kbAccountId, + secondKbPaymentMethodId, + new PluginPaymentMethodPlugin(secondKbPaymentMethodId, secondStripePaymentMethod.getId(), false, ImmutableList.of()), + false, + ImmutableList.of(), + context); + + // 4. Reach out to Stripe and ensure the attachment actually happened + PaymentMethod retrievedSecondPm = PaymentMethod.retrieve(secondStripePaymentMethod.getId(), options); + assertNotNull(retrievedSecondPm.getCustomer(), "The Stripe PaymentMethod was not attached to a customer!"); + assertEquals(retrievedSecondPm.getCustomer(), existingStripeCustomerId, "The second payment method should be attached to the ORIGINAL Stripe customer ID!"); + + // 5. Verify local Kill Bill database now correctly reflects 2 payment methods + paymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(paymentMethods.size(), 2, "Kill Bill should have exactly 2 payment methods saved locally."); + } + + @Test(groups = "integration") + public void testAddSecondTokenToExistingCustomer() throws PaymentPluginApiException, StripeException { + final UUID kbAccountId = account.getId(); + + // 1. Create the customer and the first payment method (This sets existingCustomerId) + createStripeCustomerWithCreditCardAndSyncPaymentMethod(); + final String existingStripeCustomerId = customer.getId(); + + // Verify we start with exactly 1 payment method + List initialPaymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(initialPaymentMethods.size(), 1); + + // 2. Create a raw Token in Stripe (simulating a legacy stripe.js token generation) + final RequestOptions options = stripePaymentPluginApi.buildRequestOptions(context); + final Map cardParams = new HashMap<>(); + cardParams.put("number", "4242424242424242"); + cardParams.put("exp_month", 12); + cardParams.put("exp_year", 2030); + cardParams.put("cvc", "123"); + final Map tokenParams = new HashMap<>(); + tokenParams.put("card", cardParams); + final Token stripeToken = Token.create(tokenParams, options); + + // 3. Add the token to the existing Kill Bill account + final UUID secondKbPaymentMethodId = UUID.randomUUID(); + stripePaymentPluginApi.addPaymentMethod(kbAccountId, + secondKbPaymentMethodId, + new PluginPaymentMethodPlugin(secondKbPaymentMethodId, null, false, ImmutableList.of()), + false, + ImmutableList.of(new PluginProperty("token", stripeToken.getId(), false)), + context); + + // 4. Verify local Kill Bill database now correctly reflects 2 payment methods + List paymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(paymentMethods.size(), 2, "Kill Bill should have exactly 2 payment methods saved locally."); + + // 5. VERIFY THE FIX IN STRIPE: Ensure the token was consumed and attached as a source + final Map expandParams = new HashMap<>(); + expandParams.put("expand", ImmutableList.of("sources")); + Customer retrievedCustomer = Customer.retrieve(existingStripeCustomerId, expandParams, options); + assertEquals(retrievedCustomer.getSources().getData().size(), 1, "The new card from the token should be present in the Stripe Customer's sources list."); + // Verify the card ID saved in Kill Bill matches what's in Stripe's sources + final List finalPaymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + final String secondMethodStripeId = finalPaymentMethods.stream() + .filter(pm -> !pm.getPaymentMethodId().equals(initialPaymentMethods.get(0).getPaymentMethodId())) + .findFirst() + .orElseThrow(() -> new AssertionError("Expected a second payment method in Kill Bill but none was found")) + .getExternalPaymentMethodId(); + assertEquals(retrievedCustomer.getSources().getData().get(0).getId(), secondMethodStripeId, "The card in Stripe sources should match the ID saved in Kill Bill."); + } + + @Test(groups = "integration") + public void testAddSecondSourceToExistingCustomer() throws PaymentPluginApiException, StripeException { + final UUID kbAccountId = account.getId(); + + // 1. Create the customer and the first payment method (This sets existingCustomerId) + createStripeCustomerWithCreditCardAndSyncPaymentMethod(); + final String existingStripeCustomerId = customer.getId(); + + // Verify we start with exactly 1 payment method + List paymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(paymentMethods.size(), 1); + + // 2. Create a raw Source object directly in Stripe (e.g., using an Amex test token) + final RequestOptions options = stripePaymentPluginApi.buildRequestOptions(context); + Map sourceParams = new HashMap<>(); + sourceParams.put("type", "card"); + sourceParams.put("token", "tok_amex"); + Source stripeSource = Source.create(sourceParams, options); + + // 3. Add the Source to the existing Kill Bill account + // THIS EXERCISES OUR FIX IN THE "source" BLOCK! + final UUID secondKbPaymentMethodId = UUID.randomUUID(); + stripePaymentPluginApi.addPaymentMethod(kbAccountId, + secondKbPaymentMethodId, + new PluginPaymentMethodPlugin(secondKbPaymentMethodId, null, false, ImmutableList.of()), + false, + ImmutableList.of(new PluginProperty("source", stripeSource.getId(), false)), + context); + + // 4. Verify local Kill Bill database now correctly reflects 2 payment methods + paymentMethods = stripePaymentPluginApi.getPaymentMethods(kbAccountId, false, ImmutableList.of(), context); + assertEquals(paymentMethods.size(), 2, "Kill Bill should have exactly 2 payment methods saved locally."); + + // 5. VERIFY THE FIX IN STRIPE: Ensure the source was physically attached to the customer + final Map expandParams = new HashMap<>(); + expandParams.put("expand", ImmutableList.of("sources")); + Customer retrievedCustomer = Customer.retrieve(existingStripeCustomerId, expandParams, options); + assertEquals(retrievedCustomer.getSources().getData().size(), 1, "The new source should be present in the Stripe Customer's sources list."); + // Also verify it's specifically the source we attached, not just any source + assertEquals(retrievedCustomer.getSources().getData().get(0).getId(), stripeSource.getId(), "The attached source ID should match the one we passed in."); + } @Test(groups = "integration") public void testDeletePaymentMethod() throws PaymentPluginApiException, StripeException {