Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 75 additions & 113 deletions stripe/src/main/scala/app/softnetwork/payment/config/StripeApi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import com.stripe.Stripe
import com.stripe.model.WebhookEndpoint
import com.stripe.net.RequestOptions
import com.stripe.net.RequestOptions.RequestOptionsBuilder
import com.stripe.param.{
WebhookEndpointCreateParams,
WebhookEndpointListParams,
WebhookEndpointUpdateParams
}
import com.stripe.param.{WebhookEndpointCreateParams, WebhookEndpointListParams}

import org.slf4j.{Logger, LoggerFactory}

Expand Down Expand Up @@ -126,7 +122,7 @@ object StripeApi {
val clientId = provider.providerId
val apiKey = provider.providerApiKey

// create / update stripe webhook endpoint
// (re)create stripe webhook endpoint

val hash = sha256(provider.clientId)

Expand All @@ -137,121 +133,87 @@ object StripeApi {
val url = s"${config.hooksBaseUrl}?hash=$hash"

log.info(
s"Creating / updating Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****"
s"Provisioning (delete + recreate) Stripe webhook endpoint for provider ${provider.providerId} at ${config.hooksBaseUrl}?hash=*****"
)

import scala.jdk.CollectionConverters._

Try {
((Option(
WebhookEndpoint
.list(
WebhookEndpointListParams.builder().setLimit(3L).build(),
requestOptions
// Stripe returns a webhook endpoint's signing secret ONLY at creation time — never on
// list / retrieve / update, and stripe-java 26.12.0 exposes no roll-secret API. The only
// way to guarantee that the locally stored secret matches the one Stripe actually uses is
// therefore to (re)create the endpoint and capture the secret it returns. So we always
// delete any endpoint already registered for this provider's URL, then create a fresh one.
// The URL carries the per-client hash, so we only ever match — and delete — THIS
// provider's endpoint(s), never another client's. We materialize the matches before
// deleting so paging is not disturbed by the deletions.
WebhookEndpoint
.list(
WebhookEndpointListParams.builder().setLimit(100L).build(),
requestOptions
)
.autoPagingIterable()
.asScala
.filter(endpoint => Option(endpoint.getUrl).exists(_.contains(url)))
.toList
.foreach { endpoint =>
log.info(
s"Deleting existing Stripe webhook endpoint ${endpoint.getId} to refresh its signing secret"
)
.getData
) match {
case Some(data) =>
data.asScala.headOption
case _ =>
None
}) match {
case Some(webhookEndpoint) =>
log.info(s"Webhook endpoint found: ${webhookEndpoint.getId}")
loadSecret(hash) match {
case None =>
// Not deleting the webhook endpoint, as it may be used by other clients
// Try(webhookEndpoint.delete(requestOptions))
None
Try(endpoint.delete(requestOptions)) match {
case Failure(f) =>
log.warn(
s"Failed to delete Stripe webhook endpoint ${endpoint.getId}: ${f.getMessage}"
)
case _ =>
Try(
webhookEndpoint
.update(
WebhookEndpointUpdateParams
.builder()
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.ACCOUNT__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.PERSON__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.INVOICE__PAYMENT_FAILED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.CUSTOMER__UPDATED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED
)
.addEnabledEvent(
WebhookEndpointUpdateParams.EnabledEvent.PAYMENT_METHOD__DETACHED
)
.setUrl(url)
.build(),
requestOptions
)
.getSecret // update secret if changed
).toOption
}
case _ =>
None
}).getOrElse {
WebhookEndpoint
.create(
WebhookEndpointCreateParams
.builder()
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED
)
.setUrl(url)
.setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20)
// connect=true -> events from connected accounts only; connect=false -> events from
// the platform account (customer.updated, invoice.*, subscription.*, payment_method.*).
// Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable
// after endpoint creation, so flipping this requires deleting the existing endpoint
// so it is recreated. FUTURE: support several webhook endpoints per provider (one per
// scope) — not on the roadmap and not needed by the license-server (single endpoint,
// connected=false, suffices to receive customer.updated for org sync).
.setConnect(config.connected)
.build(),
requestOptions
)
.getSecret
}
}

WebhookEndpoint
.create(
WebhookEndpointCreateParams
.builder()
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.ACCOUNT__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PERSON__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_SUCCEEDED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.INVOICE__PAYMENT_FAILED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__DELETED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__SUBSCRIPTION__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.CUSTOMER__UPDATED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__ATTACHED
)
.addEnabledEvent(
WebhookEndpointCreateParams.EnabledEvent.PAYMENT_METHOD__DETACHED
)
.setUrl(url)
.setApiVersion(WebhookEndpointCreateParams.ApiVersion.VERSION_2024_06_20)
// connect=true -> events from connected accounts only; connect=false -> events from
// the platform account (customer.updated, invoice.*, subscription.*, payment_method.*).
// Driven by payment.stripe.connected (default false). NOTE: `connect` is immutable
// after endpoint creation, so flipping this requires deleting the existing endpoint
// so it is recreated. FUTURE: support several webhook endpoints per provider (one per
// scope) — not on the roadmap and not needed by the license-server (single endpoint,
// connected=false, suffices to receive customer.updated for org sync).
.setConnect(config.connected)
.build(),
requestOptions
)
.getSecret

} match {
case Success(secret) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.typesafe.config.Config
import org.json4s.Formats
import org.slf4j.Logger

import scala.util.{Failure, Try}

trait StripeContext extends PaymentContext {

override implicit def config: StripeApi.Config
Expand Down Expand Up @@ -51,6 +53,8 @@ class StripeProviderFactory extends PaymentProviderSpi {

@volatile private[this] var _config: Option[StripeApi.Config] = None

private[this] lazy val log: Logger = org.slf4j.LoggerFactory.getLogger(getClass)

override def providerType: Provider.ProviderType = Provider.ProviderType.STRIPE

override def paymentProvider(p: Client.Provider): StripeProvider = {
Expand All @@ -64,7 +68,17 @@ class StripeProviderFactory extends PaymentProviderSpi {
override def softPaymentProvider(config: Config): Client.Provider = {
val stripeConfig = StripeSettings(config).StripeApiConfig
_config = Some(stripeConfig)
stripeConfig.softPayProvider
val provider = stripeConfig.softPayProvider
// Eagerly initialize the Stripe API at application startup so the platform webhook endpoint
// (and its signing secret) is provisioned up-front — before any payment operation or inbound
// webhook event — instead of lazily on first use. Guarded so a transient Stripe error does not
// prevent the application from starting; StripeApi() is retried on first lazy use.
Try(StripeApi()(provider, stripeConfig)) match {
case Failure(f) =>
log.warn(s"Failed to initialize Stripe API at startup: ${f.getMessage}")
case _ =>
}
provider
}

override def hooksDirectives(implicit
Expand Down
1 change: 1 addition & 0 deletions stripe/src/test/resources/reference.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
payment{
baseUrl = "http://www.softnetwork.fr:"${softnetwork.api.server.port}"/"${softnetwork.api.server.root-path}
baseUrl = ${?PAYMENT_BASE_URL}
base-url = ${payment.baseUrl}
}
Loading