diff --git a/CLAUDE.md b/CLAUDE.md
index 8fb4c8a7f3..204edbc9df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,15 +11,15 @@
> **Migration plan**: see [`LIFT_HTTP4S_MIGRATION.md`](LIFT_HTTP4S_MIGRATION.md) for the full in-place Lift → http4s strategy, file order, auth stack workstream, and progress tracker.
-The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently serves 45 endpoints; most arrived there for historical reasons and stay as-is.
+The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently serves 46 endpoints; most arrived there for historical reasons and stay as-is.
**Request priority chain** (`Http4sApp.baseServices`): `corsHandler` (OPTIONS short-circuit) → `AppsPage` → `StatusPage` → `Http4sResourceDocs` → v510 → v600 → v500 → v700 → Berlin Group v2 → UK v2.0 → UK v3.1 → Berlin Group v1.3 (+Alias) → v400 → v310 → v300 → v220 → v210 → v200 → v140 → v130 → v121 → `dynamicEntityRoutes` → `dynamicEndpointRoutes` → DirectLogin → OpenIdConnect → AliveCheck → `notFoundCatchAll` (JSON 404). There is no Lift fallback — `Http4sLiftWebBridge` has been removed. Any unhandled `/obp/*` path returns a JSON 404 from `notFoundCatchAll`; it does not fall through to Lift.
**Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures).
-**v7.0.0 native endpoints** (45 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention.
+**v7.0.0 native endpoints** (46 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestUtility, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention.
-**Tests**: `Http4s700RoutesTest` (86 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT.
+**Tests**: `Http4s700RoutesTest` (91 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT.
## Migrating a Lift Endpoint to http4s
Rules apply regardless of which version file the endpoint lives in. Use v7.0.0 only when the API signature is new or changed; otherwise migrate in-place in the original version file.
diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index f8c8be873c..dca89227ee 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -89,6 +89,7 @@ import code.group.Group
import code.organisation.Organisation
import code.routingscheme.{RoutingScheme, BankSupportedRoutingScheme}
import code.payeelookup.PayeeLookup
+import code.utilitypayment.UtilityPaymentCallback
import code.bulkpayment.{BulkPayment, BulkBatchReference}
import code.kycchecks.MappedKycCheck
import code.kycdocuments.MappedKycDocument
@@ -281,7 +282,7 @@ class Boot extends MdcLoggable {
// Please note that migration scripts are executed after Lift Mapper Schemifier
Migration.database.executeScripts(startedBeforeSchemifier = false)
- // Idempotent seed of country-qualified routing schemes (TZ.MSISDN, GePG, Luku, etc.).
+ // Idempotent seed of country-qualified routing schemes (TZ.MSISDN, bill, utility, etc.).
// Toggle off via routing_schemes.seed_defaults_at_boot=false in environments that don't want defaults.
code.routingscheme.RoutingSchemeSeed.runIfEnabled()
@@ -479,12 +480,12 @@ class Boot extends MdcLoggable {
enableVersionIfAllowed(ApiVersion.`dynamic-endpoint`)
enableVersionIfAllowed(ApiVersion.`dynamic-entity`)
- // OpenID Connect callbacks (/auth/openid-connect/callback{,-1,-2}), DirectLogin
- // (POST /my/logins/direct) and aliveCheck (GET /alive) are now served by their
- // native http4s counterparts wired into Http4sApp.baseServices
- // (Http4sOpenIdConnect / DirectLoginRoutes / AliveCheckRoutes). The Lift
- // dispatches were retired in the http4s migration; any prop gates
- // (e.g. `openid_connect.enabled`, `allow_direct_login`) live with those routes.
+ // DirectLogin (POST /my/logins/direct) and aliveCheck (GET /alive) are now served
+ // by their native http4s counterparts wired into Http4sApp.baseServices
+ // (DirectLoginRoutes / AliveCheckRoutes). The Lift dispatches were retired in the
+ // http4s migration; any prop gates (e.g. `allow_direct_login`) live with those
+ // routes. The OBP-as-relying-party OpenID Connect callback was removed: OBP is a
+ // pure OAuth2 resource server (Bearer-JWT validation), login is done by the client/BFF.
//////////////////////////////////////////////////////////////////////////////////////////////////
// Resource Docs are used in the process of surfacing endpoints so we enable them explicitly
@@ -1027,6 +1028,7 @@ object ToSchemify extends MdcLoggable {
RoutingScheme,
BankSupportedRoutingScheme,
PayeeLookup,
+ UtilityPaymentCallback,
BulkPayment,
BulkBatchReference,
AccountAccessRequest,
diff --git a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala b/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala
deleted file mode 100644
index 0d0c5747f5..0000000000
--- a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala
+++ /dev/null
@@ -1,408 +0,0 @@
-/**
-Open Bank Project - API
-Copyright (C) 2011-2019, TESOBE GmbH
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see .
-
-Email: contact@tesobe.com
-TESOBE GmbH.
-Osloer Strasse 16/17
-Berlin 13359, Germany
-
-This product includes software developed at
-TESOBE (http://www.tesobe.com/)
-
- */
-package code.api
-
-import cats.effect.IO
-import code.api.OAuth2Login.Hydra
-import code.api.util.APIUtil._
-import code.api.util.http4s.{ErrorResponseConverter, Http4sCallContextBuilder}
-import code.api.util.{APIUtil, AfterApiAuth, CustomJsonFormats, ErrorMessages, JwtUtil}
-import code.api.v6_0_0.JSONFactory600
-import code.consumer.Consumers
-import code.loginattempts.LoginAttempt
-import code.model.dataAccess.AuthUser
-import code.model.{AppType, Consumer}
-import code.token.{OpenIDConnectToken, TokensOpenIDConnect}
-import code.users.Users
-import code.util.Helper.MdcLoggable
-import com.openbankproject.commons.model.User
-import net.liftweb.common._
-import net.liftweb.db.DB
-import net.liftweb.json
-import net.liftweb.json.JsonAST.prettyRender
-import net.liftweb.json.{Extraction, Formats}
-import net.liftweb.mapper.By
-import net.liftweb.util.DefaultConnectionIdentifier
-import net.liftweb.util.Helpers
-import net.liftweb.util.Helpers._
-import org.http4s._
-import org.http4s.dsl.io._
-import org.http4s.headers.`Content-Type`
-
-import java.net.HttpURLConnection
-import javax.net.ssl.HttpsURLConnection
-
-/**
- * Per-identity-provider OpenID Connect configuration, read from
- * `openid_connect_$provider.*` props. Moved verbatim from the retired Lift
- * `openidconnect.scala`; consumed by [[Http4sOpenIdConnect]].
- */
-case class OpenIdConnectConfig(client_secret: String,
- client_id: String,
- callback_url: String,
- userinfo_endpoint: String,
- token_endpoint: String,
- authorization_endpoint: String,
- jwks_uri: String,
- access_type_offline: Boolean
- )
-
-object OpenIdConnectConfig {
- lazy val openIDConnectEnabled = code.api.Constant.openidConnectEnabled
- def getProps(props: String): String = {
- APIUtil.getPropsValue(props).getOrElse("")
- }
- def get(provider: Int): OpenIdConnectConfig = {
- OpenIdConnectConfig(
- getProps(s"openid_connect_$provider.client_secret"),
- getProps(s"openid_connect_$provider.client_id"),
- getProps(s"openid_connect_$provider.callback_url"),
- getProps(s"openid_connect_$provider.endpoint.userinfo"),
- getProps(s"openid_connect_$provider.endpoint.token"),
- getProps(s"openid_connect_$provider.endpoint.authorization"),
- getProps(s"openid_connect_$provider.endpoint.jwks_uri"),
- APIUtil.getPropsAsBoolValue(s"openid_connect_$provider.access_type_offline", false),
- )
- }
-}
-
-/**
- * Native http4s OpenID Connect callback, replacing the Lift `OpenIdConnect`
- * `serve {}` dispatch. OBP-API acts as the OIDC relying party: an external
- * provider (OBP-OIDC, Keycloak, ...) authenticates the user, redirects the
- * browser to one of these callbacks with `?code=...&state=...`, and the handler
- * exchanges the code for tokens server-side.
- *
- * Provider contract preserved unchanged: the three callback paths, the
- * form-encoded token exchange to `openid_connect_$provider.endpoint.token`
- * (reading the same `openid_connect_$provider.*` props), and JWT validation
- * against the provider's `jwks_uri`.
- *
- * Difference from the Lift version: instead of the (now-vestigial) Lift-session
- * `logUserIn` + redirect, on success we mint a usable OBP DirectLogin token and
- * return `200 {"token": "..."}`. The client then calls OBP APIs with
- * `DirectLogin: token=...`.
- *
- * Gating: the route only fires when `openid_connect.enabled=true` (default
- * false); otherwise the pattern guard fails and the request falls through to
- * `notFoundCatchAll` (JSON 404), matching prior behaviour. A second runtime gate
- * `allow_openid_connect` (default true) returns 401 when set false.
- */
-object Http4sOpenIdConnect extends MdcLoggable {
-
- private implicit val formats: Formats = CustomJsonFormats.formats
-
- // Referenced by code.api.OAuth2 (getOrCreateConsumer description); kept here as
- // the single home after the Lift OpenIdConnect object was retired.
- val openIdConnect = "OpenID Connect"
-
- // Registration gate, read per request so it stays togglable (default false).
- private def enabled: Boolean = getPropsAsBoolValue("openid_connect.enabled", false)
-
- val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
- case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback" if enabled => handle(req, 1)
- case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-1" if enabled => handle(req, 1)
- case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-2" if enabled => handle(req, 2)
- }
-
- private val jsonContentType = `Content-Type`(MediaType.application.json, Charset.`UTF-8`)
-
- private def handle(req: Request[IO], identityProvider: Int): IO[Response[IO]] =
- Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc =>
- if (!getPropsAsBoolValue("allow_openid_connect", true)) {
- ErrorResponseConverter.createErrorResponse(401, ErrorMessages.OpenIDConnectIsDisabled, cc)
- } else {
- val code = param(req, cc, "code").getOrElse("")
- val state = param(req, cc, "state").getOrElse("0")
- // The whole flow is synchronous Lift-mapper / blocking HTTP work; run it off the compute pool.
- IO.blocking(processCallback(identityProvider, code, state)).flatMap {
- case Right(token) =>
- Ok(prettyRender(Extraction.decompose(JSONFactory600.createTokenJSON(token))))
- .map(_.withContentType(jsonContentType))
- case Left((httpCode, message)) =>
- ErrorResponseConverter.createErrorResponse(httpCode, message, cc)
- }
- }
- }
-
- /** Read a parameter from the query string, falling back to a form-urlencoded body (mirrors Lift `S.param`). */
- private def param(req: Request[IO], cc: code.api.util.CallContext, name: String): Option[String] =
- req.uri.query.params.get(name).orElse {
- cc.httpBody.flatMap { body =>
- body.split("&").iterator.map(_.split("=", 2)).collectFirst {
- case Array(k, v) if java.net.URLDecoder.decode(k, "UTF-8") == name => java.net.URLDecoder.decode(v, "UTF-8")
- }
- }
- }
-
- // CONFIG CAVEAT: portal-login was removed, so nothing stores the OIDC `state` server-side;
- // `sessionState` is always "" (see processCallback). With the default
- // openid_connect.check_session_state=true this fail-closed gate rejects every real (non-empty)
- // state with 401 InvalidOpenIDConnectState — so OIDC deployments MUST set
- // openid_connect.check_session_state=false (or reintroduce server-side state storage).
- // Default kept true (fail-closed) on purpose. Long-term fix: stateless CSRF (PKCE / signed state).
- private def checkSessionState(state: String, sessionState: String): Boolean =
- if (getPropsAsBoolValue("openid_connect.check_session_state", true)) state == sessionState else true
-
- /**
- * Ports the Lift `callbackUrlCommonCode` business logic. Returns the minted OBP token on success,
- * or `(httpCode, message)` on failure. All provider-facing steps (token exchange, JWT validation)
- * and all provisioning side effects (resource user, auth user, entitlements, consumer, OIDC-token
- * persistence) are preserved verbatim.
- */
- private def processCallback(identityProvider: Int, code: String, state: String): Either[(Int, String), String] = {
- // Session state was always defaulted to "" once the portal pages were removed; preserved here.
- val sessionState = ""
- if (!checkSessionState(state, sessionState)) {
- Left((401, ErrorMessages.InvalidOpenIDConnectState))
- } else {
- exchangeAuthorizationCodeForTokens(code, identityProvider) match {
- case Full((idToken, accessToken, tokenType, expiresIn, refreshToken, scope)) =>
- JwtUtil.validateIdToken(idToken, OpenIdConnectConfig.get(identityProvider).jwks_uri) match {
- case Full(_) =>
- // Restore the single-connection-per-request semantics that Lift's removed
- // S.addAround(DB.buildLoanWrapper) gave: all provisioning writes share one
- // connection and commit together; a thrown DB error rolls the whole set back
- // (same primitive as deletion.DeletionUtil.databaseAtomicTask). The network
- // steps above (token exchange + JWKS validation) are kept OUTSIDE the tx so no
- // DB connection is held during remote HTTP calls.
- DB.use(DefaultConnectionIdentifier) { _ =>
- getOrCreateResourceUser(idToken) match {
- case Full(user) if LoginAttempt.userIsLocked(user.provider, user.name) =>
- Left((401, ErrorMessages.UsernameHasBeenLocked))
- case Full(user) =>
- getOrCreateAuthUser(user) match {
- case Full(authUser) =>
- // Grant roles according to the props email_domain_to_space_mappings
- AuthUser.grantEmailDomainEntitlementsToUser(authUser)
- AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(authUser)
- // User init actions
- AfterApiAuth.innerLoginUserInitAction(Full(authUser))
- getOrCreateConsumer(idToken, user.userId) match {
- case Full(consumer) =>
- saveAuthorizationToken(tokenType, accessToken, idToken, refreshToken, scope, expiresIn, authUser.id.get) match {
- case Full(_) =>
- // Mint a usable OBP DirectLogin token bound to the provisioned user + consumer.
- DirectLogin.issueTokenForUser(user.userPrimaryKey.value, consumer.key.get) match {
- case Full(token) => Right(token)
- case _ => Left((500, ErrorMessages.CouldNotHandleOpenIDConnectData + "issueToken"))
- }
- case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "saveAuthorizationToken"))
- }
- case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer"))
- }
- case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser"))
- }
- case _ => Left((401, ErrorMessages.CouldNotSaveOpenIDConnectUser))
- }
- }
- case _ => Left((401, ErrorMessages.CouldNotValidateIDToken))
- }
- case _ => Left((401, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens))
- }
- }
- }
-
- // ── Business-logic helpers, ported verbatim from the Lift OpenIdConnect object ────────────────────
-
- private def getOrCreateAuthUser(user: User): Box[AuthUser] = {
- AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) match {
- case Full(user) => Full(user)
- case _ => createAuthUser(user)
- }
- }
-
- private def getOrCreateResourceUser(idToken: String): Box[User] = {
- val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken)
- val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken)
- // Try to get provider from token first, fallback to Hydra resolver
- val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken))
- val providerId = preferredUsername.orElse(uniqueIdGivenByProvider)
- Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user
- Users.users.vend.createResourceUser( // Otherwise create a new one
- provider = provider,
- providerId = providerId,
- createdByConsentId = None,
- name = providerId,
- email = getClaim(name = "email", idToken = idToken),
- userId = None,
- createdByUserInvitationId = None,
- company = None,
- lastMarketingAgreementSignedDate = None
- )
- }
- }
-
- private def getClaim(name: String, idToken: String): Option[String] = {
- val claim = JwtUtil.getClaim(name = name, jwtToken = idToken)
- claim match {
- case null => None
- case string => Some(string)
- }
- }
-
- private def createAuthUser(user: User): Box[AuthUser] = tryo {
- val newUser = AuthUser.create
- .firstName(user.name)
- .email(user.emailAddress)
- .user(user.userPrimaryKey.value)
- .username(user.idGivenByProvider)
- .provider(user.provider)
- // No need to store password, so store dummy string instead
- .password(Helpers.randomString(40))
- .validated(true)
- // Save the user in order to be able to log in
- newUser.saveMe()
- }
-
- def exchangeAuthorizationCodeForTokens(authorizationCode: String, identityProvider: Int): Box[(String, String, String, Long, String, String)] = {
- val config = OpenIdConnectConfig.get(identityProvider)
- val data = "client_id=" + config.client_id + "&" +
- "client_secret=" + config.client_secret + "&" +
- "redirect_uri=" + config.callback_url + "&" +
- "code=" + authorizationCode + "&" +
- "grant_type=authorization_code"
- // Do NOT log `data` — it contains client_secret. Log the endpoint URL only.
- logger.debug("Token exchange POST to: " + config.token_endpoint)
- val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST")
- // Do NOT log the raw response — it contains id/access/refresh tokens.
- logger.debug("Token endpoint response received (success=" + response.isDefined + ")")
- response match {
- case Full(value) =>
- val tokenResponse = json.parse(value)
- for {
- idToken <- tryo{(tokenResponse \ "id_token").extractOrElse[String]("")}
- accessToken <- tryo{(tokenResponse \ "access_token").extractOrElse[String]("")}
- tokenType <- tryo{(tokenResponse \ "token_type").extractOrElse[String]("")}
- expiresIn <- tryo{(tokenResponse \ "expires_in").extractOrElse[String]("")}
- refreshToken <- tryo{(tokenResponse \ "refresh_token").extractOrElse[String]("")}
- scope <- tryo{(tokenResponse \ "scope").extractOrElse[String]("")}
- } yield {
- // Do NOT log token values (id/access/refresh). Non-sensitive metadata only.
- logger.debug(s"OIDC token parsed (tokenType=$tokenType, expiresIn=${expiresIn.toLong}, scope=$scope)")
- (idToken, accessToken, tokenType, expiresIn.toLong, refreshToken, scope)
- }
- case badObject@Failure(_, _, _) =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObject)
- badObject
- case everythingElse =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
- Failure(ErrorMessages.InternalServerError + " - exchangeAuthorizationCodeForTokens")
- }
- }
-
- private def getOrCreateConsumer(idToken: String, userId: String): Box[Consumer] = {
- Consumers.consumers.vend.getOrCreateConsumer(
- consumerId=None,
- None,
- None,
- Some(JwtUtil.getAudience(idToken).mkString(",")),
- getClaim(name = "azp", idToken = idToken),
- JwtUtil.getIssuer(idToken),
- JwtUtil.getSubject(idToken),
- Some(true),
- name = Some(Helpers.randomString(10).toLowerCase),
- appType = Some(AppType.Confidential),
- description = Some(openIdConnect),
- developerEmail = getClaim(name = "email", idToken = idToken),
- redirectURL = None,
- createdByUserId = Some(userId)
- )
- }
-
- private def saveAuthorizationToken(tokenType: String,
- accessToken: String,
- idToken: String,
- refreshToken: String,
- scope: String,
- expiresIn: Long,
- authUserPrimaryKey: Long): Box[OpenIDConnectToken] = {
- val token = TokensOpenIDConnect.tokens.vend.createToken(
- tokenType = tokenType,
- accessToken = accessToken,
- idToken = idToken,
- refreshToken = refreshToken,
- scope = scope,
- expiresIn = expiresIn,
- authUserPrimaryKey = authUserPrimaryKey
- )
- token match {
- case Full(_) => // All good
- case error => logger.error(error)
- }
- token
- }
-
- def fromUrl( url: String,
- data: String = "",
- method: String,
- connectTimeout: Int = 2000,
- readTimeout: Int = 10000
- ): Box[String] = {
- var content:String = ""
- import java.net.URL
- try {
- val connection = {
- if (url.startsWith("https://")) {
- val conn: HttpsURLConnection = new URL(url + {
- if (method == "GET") data
- else ""
- }).openConnection.asInstanceOf[HttpsURLConnection]
- conn
- }
- else {
- val conn: HttpURLConnection = new URL(url + {
- if (method == "GET") data
- else ""
- }).openConnection.asInstanceOf[HttpURLConnection]
- conn
- }
- }
- connection.setConnectTimeout(connectTimeout)
- connection.setReadTimeout(readTimeout)
- connection.setRequestMethod(method)
- connection.setRequestProperty("Accept", "application/json")
- if ( data != "" && method == "POST") {
- connection.setRequestProperty("Content-type", "application/x-www-form-urlencoded")
- connection.setRequestProperty("Charset", "utf-8")
- val dataBytes = data.getBytes("UTF-8")
- connection.setRequestProperty("Content-Length", dataBytes.length.toString)
- connection.setDoOutput( true )
- connection.getOutputStream.write(dataBytes)
- }
- val inputStream = connection.getInputStream
- content = scala.io.Source.fromInputStream(inputStream).mkString
- if (inputStream != null) inputStream.close()
- Full(content)
- } catch {
- case e:Throwable =>
- e.printStackTrace()
- logger.error(e)
- Failure(e.getMessage)
- }
- }
-}
diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala
index ca359ebfa4..23b345f13b 100644
--- a/obp-api/src/main/scala/code/api/OAuth2.scala
+++ b/obp-api/src/main/scala/code/api/OAuth2.scala
@@ -533,7 +533,7 @@ object OAuth2Login extends MdcLoggable {
case Full(_) =>
logger.debug("applyIdTokenRules - ID token validation successful")
val user = getOrCreateResourceUser(token)
- val consumer = getOrCreateConsumer(token, user.map(_.userId), Some(Http4sOpenIdConnect.openIdConnect))
+ val consumer = getOrCreateConsumer(token, user.map(_.userId), Some("OpenID Connect"))
LoginAttempt.userIsLocked(user.map(_.provider).getOrElse(""), user.map(_.name).getOrElse("")) match {
case true => ((Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer))))
case false => (user, Some(cc.copy(consumer = consumer)))
diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala
index dc28977a05..01e9b66c4e 100644
--- a/obp-api/src/main/scala/code/api/directlogin.scala
+++ b/obp-api/src/main/scala/code/api/directlogin.scala
@@ -475,17 +475,6 @@ object DirectLogin extends MdcLoggable {
}
}
- /**
- * Mint and persist a usable DirectLogin token for an already-authenticated user, bypassing the
- * username/password validation in `createTokenCommonPart`. Used by the http4s OpenID Connect
- * callback (`Http4sOpenIdConnect`) once the provider has verified the user's identity.
- */
- def issueTokenForUser(userPrimaryKey: Long, consumerKey: String): Box[String] = {
- val (token, secret) = generateTokenAndSecret(JWTClaimsSet.parse("""{"":""}"""))
- if (saveAuthorizationToken(Map("consumer_key" -> consumerKey), token, secret, userPrimaryKey)) Full(token)
- else Failure("OpenIDConnect: could not persist DirectLogin token")
- }
-
def getUser : Box[User] = {
val httpMethod = "GET"
val (httpCode, message, directLoginParameters) = validator("protectedResource")
diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
index f5e8e0f416..30928e8946 100644
--- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
+++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
@@ -508,6 +508,15 @@ object ErrorMessages {
val BulkPaymentAddressMismatch = "OBP-30543: A payment's address does not match the address_pattern of its routing_scheme."
val BulkPaymentTransactionRequestError = "OBP-30544: Could not create BULK transaction request."
+ // UTILITY transaction-request (OBP-30546 .. OBP-30549)
+ // Polymorphic bill/utility payment (prepaid utility meter, bill control number, ...).
+ // The destination is identified by a QualifiedIdentifier whose `scheme` must be a
+ // registered routing scheme of category UTILITY or BILL.
+ val UtilityIdentifierTypeWrongCategory = "OBP-30546: identifier scheme category is not valid for a UTILITY payment. Allowed categories are: UTILITY, BILL."
+ val UtilityInvalidIdentifier = "OBP-30547: Invalid identifier value — does not match the address_pattern of the routing scheme (e.g. TZ.UTILITY_METER)."
+ val UtilityDestinationNotFound = "OBP-30548: No biller/utility account is registered for the supplied identifier. In mapped mode the destination must have an account routing for the identifier scheme (e.g. TZ.UTILITY_METER)."
+ val UtilityPaymentError = "OBP-30549: Could not create UTILITY transaction request."
+
// Implicit OBP-family routing schemes (OBP-30545)
// The schemes "OBP" / "OBP_ACCOUNT_ID" / "OBP_BANK_ID" are reserved self-identifiers —
// they map (scheme, address) directly to (kind, primary_key) without a stored row.
diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala
index 50c392b0cb..65b44bcf81 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -2309,7 +2309,7 @@ object Glossary extends MdcLoggable {
|
| GET /obp/v3.0.0/users/current HTTP/1.1
| Host: $getServerUrl
-| Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA
+| Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA
| Cache-Control: no-cache
|
|
@@ -2319,7 +2319,7 @@ object Glossary extends MdcLoggable {
|
| curl -X GET
| $getServerUrl/obp/v3.0.0/users/current
-| -H 'Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA'
+| -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA'
| -H 'Cache-Control: no-cache'
| -H 'Postman-Token: aa812d04-eddd-4752-adb7-4d56b3a98f36'
|
diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
index 8f9f568857..f678a66bf1 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
@@ -144,7 +144,6 @@ object Http4sApp extends MdcLoggable {
.orElse(dynamicEntityRoutes.run(req))
.orElse(dynamicEndpointRoutes.run(req))
.orElse(code.api.DirectLoginRoutes.routes.run(req))
- .orElse(code.api.Http4sOpenIdConnect.routes.run(req))
.orElse(code.api.AliveCheckRoutes.routes.run(req))
.orElse(notFoundCatchAll.run(req))
}
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
index 32e85eb914..0717fa0c1a 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
@@ -29,6 +29,7 @@ import code.entitlement.Entitlement
import code.organisation.Organisations
import code.routingscheme.{RoutingSchemes, RoutingSchemeValidation}
import code.payeelookup.PayeeLookups
+import code.utilitypayment.{UtilityCallbackDispatcher, UtilityPaymentCallbacks}
import code.bulkpayment.{BulkPaymentHandler, BulkPayments}
import code.transactionrequests.MappedTransactionRequestProvider
import com.openbankproject.commons.model.TransactionRequestCharge
@@ -2241,7 +2242,7 @@ object Http4s700 {
// ── Routing Schemes ───────────────────────────────────────────────────────
// A registry of country-qualified routing scheme names (e.g. TZ.MSISDN,
- // TZ.GEPG_CONTROL_NUMBER) so that downstream adapters and clients agree on
+ // TZ.BILL_CONTROL_NUMBER) so that downstream adapters and clients agree on
// identifier scheme semantics. Two tiers:
// • /routing-schemes — system catalogue (5 endpoints)
// • /banks/BANK_ID/supported-routing-schemes — per-bank subset (2 endpoints)
@@ -2300,7 +2301,7 @@ object Http4s700 {
"Create Routing Scheme",
"""Register a new routing scheme.
|
- |Scheme names follow the convention `.` — uppercase ISO 3166-1 alpha-2 country code, a dot, then an uppercase local scheme name (e.g. `TZ.MSISDN`, `TZ.GEPG_CONTROL_NUMBER`).
+ |Scheme names follow the convention `.` — uppercase ISO 3166-1 alpha-2 country code, a dot, then an uppercase local scheme name (e.g. `TZ.MSISDN`, `TZ.BILL_CONTROL_NUMBER`).
|
|Globally-unique schemes `IBAN`, `BIC`, `OBP` are accepted unprefixed; their `country` MUST be the literal `INT`.
|
@@ -2376,7 +2377,7 @@ object Http4s700 {
|- `country` — ISO 3166-1 alpha-2, e.g. `TZ`
|- `category` — ACCOUNT, BANK, BRANCH, IDENTITY, BILL, UTILITY
|- `status` — defaults to `ACTIVE`. Pass `ALL` to include DEPRECATED and RETIRED.
- |- `rail` — match against the `downstream_rails` list (e.g. `TIPS`, `GEPG`)
+ |- `rail` — match against the `downstream_rails` list (e.g. `TIPS`, `RTGS`)
|- `limit` (default 100, max 500), `offset` (default 0)""".stripMargin,
EmptyBody,
JSONFactory700.RoutingSchemesJsonV700(
@@ -2564,11 +2565,11 @@ object Http4s700 {
|Authentication is Required.""".stripMargin,
EmptyBody,
JSONFactory700.BankSupportedRoutingSchemesJsonV700(
- bank_id = "nmb.tz",
+ bank_id = "bank.tz",
supported_routing_schemes = List(
JSONFactory700.BankSupportedRoutingSchemeJsonV700(
scheme = "TZ.MSISDN",
- bank_notes = Some("Routed via Gateway X to TIPS.")
+ bank_notes = Some("Routed via the instant-payment rail (TIPS).")
)
)
),
@@ -2616,12 +2617,12 @@ object Http4s700 {
|
|Authentication is Required.""".stripMargin,
JSONFactory700.PutBankSupportedRoutingSchemeJsonV700(
- bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT."),
+ bank_notes = Some("Routed via the instant-payment rail (TIPS). Daily cutoff 22:00 EAT."),
enabled = Some(true)
),
JSONFactory700.BankSupportedRoutingSchemeJsonV700(
scheme = "TZ.MSISDN",
- bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT.")
+ bank_notes = Some("Routed via the instant-payment rail (TIPS). Daily cutoff 22:00 EAT.")
),
List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat,
BankNotFound, RoutingSchemeNotFound, RoutingSchemeNotSupportedByBank,
@@ -2721,8 +2722,8 @@ object Http4s700 {
|Examples:
|- Mobile-money / TIPS payee: `identifier: { scheme: TZ.MSISDN, value: 255778300336, fsp_id: 503 }`
|- TIPS bank-account name verify: `identifier: { scheme: TZ.BANK_ACCOUNT, value: 24110000296 }`
- |- GePG bill inquiry: `identifier: { scheme: TZ.GEPG_CONTROL_NUMBER, value: 991043383705 }`
- |- Luku meter inquiry: `identifier: { scheme: TZ.LUKU_METER, value: 24730238417 }`
+ |- Bill inquiry: `identifier: { scheme: TZ.BILL_CONTROL_NUMBER, value: 991043383705 }`
+ |- Utility meter inquiry: `identifier: { scheme: TZ.UTILITY_METER, value: 24730238417 }`
|
|The response includes a `lookup_id` valid for 10 minutes. A subsequent transaction-request can quote it via `verified_payee_lookup_id` to prove the payer saw the resolved name (Confirmation-of-Payee handshake).
|
@@ -2738,8 +2739,8 @@ object Http4s700 {
identifier = JSONFactory700.QualifiedIdentifierJsonV700(
scheme = "TZ.MSISDN", value = "255778300336", fsp_id = Some("503")
),
- network_provider = Some("ZANTEL"),
- full_name = "ERASTO EMILE MALEMA",
+ network_provider = Some("PROVIDERA"),
+ full_name = "Jane Doe",
account_category = Some("PERSON"),
account_type = Some("WALLET"),
identity = None
@@ -2827,15 +2828,15 @@ object Http4s700 {
to = JSONFactory700.MobileWalletToJsonV700(
msisdn = "255778300336",
fsp_id = Some("503"),
- network_provider = Some("AIRTEL"),
- full_name = Some("Chinua Achebe"),
+ network_provider = Some("PROVIDERA"),
+ full_name = Some("Jane Doe"),
account_category = Some("PERSON"),
account_type = Some("WALLET"),
identity = None
),
value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "1000"),
- description = "buy airtime",
- client_reference = Some("MK45078200"),
+ description = "wallet payment",
+ client_reference = Some("ref-0001"),
verified_payee_lookup_id = None,
country_code = Some("TZ"),
data_fields = Some(List(JSONFactory700.MobileWalletDataFieldJsonV700("fieldName1", "fieldValue1"))),
@@ -2888,6 +2889,179 @@ object Http4s700 {
// ── End MOBILE_WALLET ─────────────────────────────────────────────────────
+ // ── UTILITY transaction request ───────────────────────────────────────────
+ // Polymorphic bill / utility payment (prepaid utility meter token purchase, bill
+ // payment, ...). The destination biller is identified by a QualifiedIdentifier
+ // whose `scheme` must be a registered routing scheme of category UTILITY or BILL —
+ // e.g. TZ.UTILITY_METER (prepaid electricity meter). Verify the destination first
+ // via POST .../payees/lookup, then pay quoting `verified_payee_lookup_id`
+ // (Confirmation-of-Payee handshake). Plugs into the v400 payment pipeline.
+ // If `callback_url` is supplied, a one-shot callback is registered and the
+ // result is POSTed back asynchronously — a failed callback never fails the
+ // payment.
+ val UtilityValidCategories: Set[String] = Set("UTILITY", "BILL")
+
+ val createTransactionRequestUtility: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "UTILITY" / "transaction-requests" =>
+ EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyUtilityJsonV700, JSONFactory700.TransactionRequestWithChargeUtilityJsonV700](req) { (user, fromAccount, view, body, cc) =>
+ val callCtx = Some(cc)
+ val chargePolicy = body.charge_policy.getOrElse("SHARED")
+ for {
+ // 1. identifier.scheme must be a registered routing scheme.
+ scheme <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(body.to.scheme))
+ .map(unboxFullOrFail(_, callCtx, PayeeLookupIdentifierTypeNotRegistered, 400))
+ // 2. scheme category must be UTILITY or BILL.
+ _ <- Helper.booleanToFuture(UtilityIdentifierTypeWrongCategory, 400, callCtx) {
+ UtilityValidCategories.contains(scheme.category)
+ }
+ // 3. identifier.value must match the scheme's address_pattern.
+ _ <- Helper.booleanToFuture(UtilityInvalidIdentifier, 400, callCtx) {
+ RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.to.value)
+ }
+ // 4. optional Confirmation-of-Payee handshake against a prior lookup.
+ _ <- body.verified_payee_lookup_id match {
+ case Some(lkpId) =>
+ for {
+ lkp <- Future(PayeeLookups.payeeLookup.vend.getActivePayeeLookup(lkpId))
+ .map(unboxFullOrFail(_, callCtx, PayeeLookupExpiredOrNotFound, 400))
+ _ <- Helper.booleanToFuture(PayeeLookupMismatch, 400, callCtx) {
+ lkp.identifier == body.to.value && lkp.identifierType == body.to.scheme
+ }
+ } yield ()
+ case None => Future.successful(())
+ }
+ // 5. resolve the destination biller/utility account via routing.
+ destinationBox <- BankConnector.connector.vend
+ .getBankAccountByRouting(None, body.to.scheme, body.to.value, callCtx)
+ .map(_._1)
+ toAccount <- Future {
+ unboxFullOrFail(destinationBox, callCtx, UtilityDestinationNotFound, 404)
+ }
+ // 6. standard view authorisation check (same as v4 COUNTERPARTY).
+ _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest(
+ view.viewId, BankIdAccountId(fromAccount.bankId, fromAccount.accountId), user, callCtx
+ )
+ // 7. serialise the body to JSON for the connector's audit blob.
+ detailsPlain = prettyRender(Extraction.decompose(body))
+ // 8. create the transaction request via the standard pipeline.
+ txnReqType = TransactionRequestType("UTILITY")
+ (tr, _) <- NewStyle.function.createTransactionRequestv400(
+ user,
+ view.viewId,
+ fromAccount,
+ toAccount,
+ txnReqType,
+ body,
+ detailsPlain,
+ chargePolicy,
+ Some(ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE),
+ None,
+ None,
+ callCtx
+ )
+ // 9. register + fire the one-shot result callback (step c), if asked.
+ callbackJson = body.callback_url.flatMap { url =>
+ val callbackId = APIUtil.generateUUID()
+ UtilityPaymentCallbacks.utilityPaymentCallback.vend.createCallback(
+ callbackId = callbackId,
+ transactionRequestId = tr.id.value,
+ callbackUrl = url,
+ identifierType = body.to.scheme,
+ identifier = body.to.value,
+ fromBankId = fromAccount.bankId.value,
+ fromAccountId = fromAccount.accountId.value,
+ createdByUserId = user.userId
+ ).toOption.map { stored =>
+ // Deliver the TR result asynchronously; pass callback=None so the
+ // delivered payload carries the payment result, not its own status.
+ val payload = prettyRender(Extraction.decompose(
+ JSONFactory700.createTransactionRequestWithChargeUtilityJsonV700(tr, body, None, Nil, Nil)
+ ))
+ UtilityCallbackDispatcher.deliver(callbackId, url, payload)
+ JSONFactory700.UtilityCallbackJsonV700(
+ callback_id = stored.callbackId,
+ callback_url = stored.callbackUrl,
+ status = stored.status
+ )
+ }
+ }
+ } yield JSONFactory700.createTransactionRequestWithChargeUtilityJsonV700(tr, body, callbackJson, Nil, Nil)
+ }
+ }
+
+ val utilityBodyExample = JSONFactory700.TransactionRequestBodyUtilityJsonV700(
+ to = JSONFactory700.QualifiedIdentifierJsonV700(
+ scheme = "TZ.UTILITY_METER", value = "24730238417", fsp_id = None
+ ),
+ value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "1000"),
+ description = "Prepaid utility meter token purchase",
+ client_reference = Some("ref-0001"),
+ verified_payee_lookup_id = None,
+ payer = Some(JSONFactory700.UtilityPayerJsonV700(
+ phone = Some("255700000000"),
+ name = Some("Jane Doe"),
+ email = Some("jane.doe@example.com")
+ )),
+ callback_url = Some("https://example.com/utility/callback"),
+ data_fields = None,
+ charge_policy = Some("SHARED")
+ )
+
+ resourceDocs += ResourceDoc(
+ implementedInApiVersion,
+ nameOf(createTransactionRequestUtility),
+ "POST",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/UTILITY/transaction-requests",
+ "Create Transaction Request (UTILITY)",
+ """Initiate a bill / utility payment — e.g. a prepaid-electricity meter token purchase, or a bill payment.
+ |
+ |The endpoint is **polymorphic on `to.scheme`**: the destination biller is identified by a `QualifiedIdentifier` whose `scheme` must be a registered routing scheme of category **UTILITY** or **BILL** (e.g. `TZ.UTILITY_METER`, `TZ.BILL_CONTROL_NUMBER`). The scheme must be registered in the routing-scheme catalogue (`GET /obp/v7.0.0/routing-schemes/TZ.UTILITY_METER`) and `to.value` must match its `address_pattern`.
+ |
+ |**Confirmation-of-Payee handshake** (recommended): call `POST /banks/.../accounts/.../payees/lookup` first (the meter-number / control-number inquiry), then pass the returned `lookup_id` here as `verified_payee_lookup_id`. The endpoint rejects the request if the lookup has expired or does not match the supplied identifier.
+ |
+ |**Payer block**: `payer` carries the depositor's phone / name / email for the biller receipt.
+ |
+ |**Callback** (optional): supply `callback_url` to register a one-shot callback; OBP POSTs the final transaction-request result to that URL asynchronously. A failed or unreachable callback never fails the payment.
+ |
+ |**Provider passthrough**: `data_fields` carries arbitrary name/value pairs that adapters forward to the downstream rail without OBP interpretation.
+ |
+ |Authentication is Required.""".stripMargin,
+ utilityBodyExample,
+ JSONFactory700.TransactionRequestWithChargeUtilityJsonV700(
+ id = "4050046c-63b3-4868-8a22-14b4181d33a6",
+ `type` = "UTILITY",
+ from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140(
+ bank_id = "gh.29.uk",
+ account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f1"
+ ),
+ details = utilityBodyExample,
+ transaction_ids = List("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1"),
+ status = "COMPLETED",
+ start_date = code.api.util.APIUtil.DateWithDayExampleObject,
+ end_date = code.api.util.APIUtil.DateWithDayExampleObject,
+ challenges = Nil,
+ charge = code.api.v2_0_0.TransactionRequestChargeJsonV200(
+ summary = "Total charges for completed transaction",
+ value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "0.00")
+ ),
+ callback = Some(JSONFactory700.UtilityCallbackJsonV700(
+ callback_id = "cbk_01HXY7Z8AB9C0D1E2F3G4H5J6K",
+ callback_url = "https://example.com/utility/callback",
+ status = "REGISTERED"
+ )),
+ attributes = None
+ ),
+ List($AuthenticatedUserIsRequired, InvalidJsonFormat,
+ PayeeLookupIdentifierTypeNotRegistered, UtilityIdentifierTypeWrongCategory,
+ UtilityInvalidIdentifier, PayeeLookupExpiredOrNotFound, PayeeLookupMismatch,
+ UtilityDestinationNotFound, UtilityPaymentError, UnknownError),
+ apiTagTransactionRequest :: apiTagPayee :: Nil,
+ None,
+ http4sPartialFunction = Some(createTransactionRequestUtility)
+ )
+
+ // ── End UTILITY ───────────────────────────────────────────────────────────
+
// ── OPEN_CORRIDOR transaction request ─────────────────────────────────────
// Travel-Rule-friendly TR with FATF Recommendation 16 originator block.
// Money-movement is identical to SIMPLE; the originator is persisted as a
@@ -3115,7 +3289,7 @@ object Http4s700 {
batch_reference = "BATCH-2026-05-13-001",
status = "COMPLETED",
from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140(
- bank_id = "nmb.tz", account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0"
+ bank_id = "bank.tz", account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0"
),
total_value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "75000.00"),
total_payments = 2,
diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
index e0e0529ad3..61407d9aa0 100644
--- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
+++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala
@@ -766,6 +766,92 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats {
)
}
+ // ── UTILITY transaction-request body ───────────────────────────────────────
+ //
+ // A polymorphic bill / utility payment. The destination is a QualifiedIdentifier
+ // whose `scheme` must be a registered routing scheme of category UTILITY or BILL
+ // — e.g. `TZ.UTILITY_METER` (prepaid electricity meter), later `TZ.BILL_CONTROL_NUMBER`.
+ // Mirrors the meter/bill token-purchase flow: verify the destination via
+ // POST .../payees/lookup, then pay quoting `verified_payee_lookup_id`.
+
+ /** Payer block — the depositor's phone / name / email for the biller receipt. */
+ case class UtilityPayerJsonV700(
+ phone: Option[String],
+ name: Option[String],
+ email: Option[String]
+ )
+
+ /**
+ * Body for `POST .../transaction-request-types/UTILITY/transaction-requests`.
+ *
+ * Implements `TransactionRequestCommonBodyJSON` so it plugs into the existing
+ * v400 transaction-request pipeline (which requires `value` + `description`).
+ *
+ * `callback_url`, when present, registers a fire-and-forget callback that OBP
+ * POSTs the final token-purchase result to.
+ */
+ case class TransactionRequestBodyUtilityJsonV700(
+ to: QualifiedIdentifierJsonV700,
+ value: com.openbankproject.commons.model.AmountOfMoneyJsonV121,
+ description: String,
+ client_reference: Option[String],
+ verified_payee_lookup_id: Option[String],
+ payer: Option[UtilityPayerJsonV700],
+ callback_url: Option[String],
+ data_fields: Option[List[MobileWalletDataFieldJsonV700]],
+ charge_policy: Option[String]
+ ) extends com.openbankproject.commons.model.TransactionRequestCommonBodyJSON
+
+ /** Registration status of the per-request callback (step c). */
+ case class UtilityCallbackJsonV700(
+ callback_id: String,
+ callback_url: String,
+ status: String // REGISTERED | DELIVERED | FAILED
+ )
+
+ // v7 response shape for UTILITY. Mirrors MOBILE_WALLET's wrapper and adds the
+ // optional callback-registration block.
+ case class TransactionRequestWithChargeUtilityJsonV700(
+ id: String,
+ `type`: String,
+ from: code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140,
+ details: TransactionRequestBodyUtilityJsonV700,
+ transaction_ids: List[String],
+ status: String,
+ start_date: java.util.Date,
+ end_date: java.util.Date,
+ challenges: List[code.api.v4_0_0.ChallengeJsonV400],
+ charge: code.api.v2_0_0.TransactionRequestChargeJsonV200,
+ callback: Option[UtilityCallbackJsonV700],
+ attributes: Option[List[code.api.v4_0_0.BankAttributeBankResponseJsonV400]]
+ )
+
+ def createTransactionRequestWithChargeUtilityJsonV700(
+ tr: com.openbankproject.commons.model.TransactionRequest,
+ requestBody: TransactionRequestBodyUtilityJsonV700,
+ callback: Option[UtilityCallbackJsonV700],
+ challenges: List[com.openbankproject.commons.model.ChallengeTrait],
+ transactionRequestAttribute: List[com.openbankproject.commons.model.TransactionRequestAttributeTrait]
+ ): TransactionRequestWithChargeUtilityJsonV700 = {
+ val v4 = code.api.v4_0_0.JSONFactory400.createTransactionRequestWithChargeJSON(
+ tr, challenges, transactionRequestAttribute
+ )
+ TransactionRequestWithChargeUtilityJsonV700(
+ id = v4.id,
+ `type` = v4.`type`,
+ from = v4.from,
+ details = requestBody,
+ transaction_ids = v4.transaction_ids,
+ status = v4.status,
+ start_date = v4.start_date,
+ end_date = v4.end_date,
+ challenges = v4.challenges,
+ charge = v4.charge,
+ callback = callback,
+ attributes = v4.attributes
+ )
+ }
+
// ── BULK transaction-request body ─────────────────────────────────────────
case class BulkPaymentItemJsonV700(
diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala
index 216c240278..44893f9d43 100644
--- a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala
+++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala
@@ -43,8 +43,8 @@ object RoutingSchemeSeed {
"TIPS Financial Service Provider code (3 digits).",
List("TIPS")),
Entry("TZ.NETWORK_PROVIDER", "TZ", "BANK",
- "^[A-Z]+$", "AIRTEL",
- "Mobile network operator name (AIRTEL, MPESA, VODACOM, HALOTEL, TTCL, MIX, ZANTEL).",
+ "^[A-Z]+$", "PROVIDERA",
+ "Mobile network operator short name (uppercase letters).",
List("MNO_DIRECT")),
Entry("TZ.BANK_ACCOUNT", "TZ", "ACCOUNT",
"^[0-9]{8,16}$", "24110000296",
@@ -52,27 +52,27 @@ object RoutingSchemeSeed {
List("TIPS", "RTGS")),
Entry("TZ.BANK_CODE", "TZ", "BANK",
"^[0-9]{3}$", "003",
- "Tanzanian domestic bank code (e.g. NMB = 003).",
+ "Tanzanian domestic bank code (3 digits).",
List("TIPS", "RTGS")),
Entry("TZ.BRANCH_CODE", "TZ", "BRANCH",
"^[0-9]{3}$", "208",
"Tanzanian branch routing code.",
List("RTGS")),
- Entry("TZ.GEPG_CONTROL_NUMBER", "TZ", "BILL",
+ Entry("TZ.BILL_CONTROL_NUMBER", "TZ", "BILL",
"^[0-9]{12}$", "991043383705",
- "GePG (Government e-Payment Gateway) bill control number.",
- List("GEPG")),
- Entry("TZ.GEPG_SP_CODE", "TZ", "BILL",
+ "Government / biller payment control number.",
+ List("BILL")),
+ Entry("TZ.BILL_SP_CODE", "TZ", "BILL",
"^SP[0-9]{5}$", "SP99103",
- "GePG service-provider code.",
- List("GEPG")),
- Entry("TZ.LUKU_METER", "TZ", "UTILITY",
+ "Biller service-provider code.",
+ List("BILL")),
+ Entry("TZ.UTILITY_METER", "TZ", "UTILITY",
"^[0-9]{8,14}$", "24730238417",
- "TANESCO LUKU prepaid electricity meter number.",
- List("LUKU")),
+ "Prepaid electricity / utility meter number.",
+ List("UTILITY")),
Entry("TZ.NIN", "TZ", "IDENTITY",
"^[0-9]{20}$", "19331007175010005135",
- "Tanzania National Identification Number (NIDA).",
+ "Tanzania National Identification Number.",
Nil),
Entry("TZ.TIN", "TZ", "IDENTITY",
"^[0-9]{9}$", "123456789",
diff --git a/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala b/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala
new file mode 100644
index 0000000000..30ac081178
--- /dev/null
+++ b/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala
@@ -0,0 +1,57 @@
+package code.utilitypayment
+
+import java.io.IOException
+
+import code.util.Helper.MdcLoggable
+import okhttp3._
+
+/**
+ * Fire-and-forget delivery of UTILITY payment callbacks.
+ *
+ * On a UTILITY transaction-request that carried a `callback_url`, the endpoint
+ * persists a [[UtilityPaymentCallback]] row and calls [[deliver]] with the final
+ * result payload. We POST asynchronously and record the outcome on the row; the
+ * caller's request is never blocked on the callback, and a failed/unreachable
+ * callback URL does not fail the payment.
+ */
+object UtilityCallbackDispatcher extends MdcLoggable {
+
+ private val client = new OkHttpClient
+ private val jsonType = MediaType.parse("application/json; charset=utf-8")
+
+ /**
+ * @param callbackId the persisted UtilityPaymentCallback.CallbackId
+ * @param callbackUrl absolute URL to POST the result to
+ * @param payload JSON body (already rendered)
+ */
+ def deliver(callbackId: String, callbackUrl: String, payload: String): Unit = {
+ val body = RequestBody.create(jsonType, payload)
+ val request = new Request.Builder().url(callbackUrl).post(body).build()
+ try {
+ client.newCall(request).enqueue(new Callback() {
+ def onFailure(call: Call, e: IOException): Unit = {
+ logger.warn(s"[UtilityCallbackDispatcher] delivery failed for callbackId=$callbackId url=$callbackUrl: ${e.getMessage}")
+ UtilityPaymentCallbacks.utilityPaymentCallback.vend
+ .recordAttempt(callbackId, UtilityCallbackStatus.Failed, None)
+ }
+
+ def onResponse(call: Call, response: Response): Unit = {
+ val responseBody = response.body
+ try {
+ val code = response.code()
+ val status = if (response.isSuccessful) UtilityCallbackStatus.Delivered else UtilityCallbackStatus.Failed
+ logger.debug(s"[UtilityCallbackDispatcher] callbackId=$callbackId url=$callbackUrl responded $code")
+ UtilityPaymentCallbacks.utilityPaymentCallback.vend
+ .recordAttempt(callbackId, status, Some(code.toString))
+ } finally if (responseBody != null) responseBody.close()
+ }
+ })
+ } catch {
+ // Malformed URL or client-level failure — record and swallow; never fail the payment.
+ case e: Exception =>
+ logger.warn(s"[UtilityCallbackDispatcher] could not enqueue callbackId=$callbackId url=$callbackUrl: ${e.getMessage}")
+ UtilityPaymentCallbacks.utilityPaymentCallback.vend
+ .recordAttempt(callbackId, UtilityCallbackStatus.Failed, None)
+ }
+ }
+}
diff --git a/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala b/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala
new file mode 100644
index 0000000000..4cb8dbeab9
--- /dev/null
+++ b/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala
@@ -0,0 +1,153 @@
+package code.utilitypayment
+
+import net.liftweb.common.Box
+import net.liftweb.mapper._
+import net.liftweb.util.Helpers.tryo
+import net.liftweb.util.SimpleInjector
+
+/**
+ * Per-request callback registry for UTILITY transaction-requests. When a caller
+ * supplies a `callback_url` on a UTILITY payment, OBP persists a row here and
+ * fires a fire-and-forget POST of the final result to that URL via
+ * [[UtilityCallbackDispatcher]].
+ *
+ * Distinct from the account-event webhook system (code.webhook.*): those are
+ * standing, account-scoped subscriptions; this is a one-shot callback bound to
+ * a single transaction request.
+ */
+object UtilityPaymentCallbacks extends SimpleInjector {
+ val utilityPaymentCallback = new Inject(buildOne _) {}
+
+ def buildOne: UtilityPaymentCallbackProvider = MappedUtilityPaymentCallbackProvider
+}
+
+object UtilityCallbackStatus {
+ val Registered = "REGISTERED"
+ val Delivered = "DELIVERED"
+ val Failed = "FAILED"
+}
+
+trait UtilityPaymentCallbackProvider {
+ def createCallback(
+ callbackId: String,
+ transactionRequestId: String,
+ callbackUrl: String,
+ identifierType: String,
+ identifier: String,
+ fromBankId: String,
+ fromAccountId: String,
+ createdByUserId: String
+ ): Box[UtilityPaymentCallbackTrait]
+
+ def getCallbackByTransactionRequestId(transactionRequestId: String): Box[UtilityPaymentCallbackTrait]
+
+ /** Record a delivery attempt outcome (increments the attempt counter). */
+ def recordAttempt(
+ callbackId: String,
+ status: String,
+ responseCode: Option[String]
+ ): Box[UtilityPaymentCallbackTrait]
+}
+
+trait UtilityPaymentCallbackTrait {
+ def callbackId: String
+ def transactionRequestId: String
+ def callbackUrl: String
+ def identifierType: String
+ def identifier: String
+ def fromBankId: String
+ def fromAccountId: String
+ def createdByUserId: String
+ def status: String
+ def attempts: Int
+ def responseCode: Option[String]
+ def createdAt: java.util.Date
+ def lastAttemptAt: Option[java.util.Date]
+}
+
+object MappedUtilityPaymentCallbackProvider extends UtilityPaymentCallbackProvider {
+
+ override def createCallback(
+ callbackId: String,
+ transactionRequestId: String,
+ callbackUrl: String,
+ identifierType: String,
+ identifier: String,
+ fromBankId: String,
+ fromAccountId: String,
+ createdByUserId: String
+ ): Box[UtilityPaymentCallbackTrait] = tryo {
+ UtilityPaymentCallback.create
+ .CallbackId(callbackId)
+ .TransactionRequestId(transactionRequestId)
+ .CallbackUrl(callbackUrl)
+ .IdentifierType(identifierType)
+ .Identifier(identifier)
+ .FromBankId(fromBankId)
+ .FromAccountId(fromAccountId)
+ .CreatedByUserId(createdByUserId)
+ .Status(UtilityCallbackStatus.Registered)
+ .Attempts(0)
+ .CreationDate(new java.util.Date())
+ .saveMe()
+ }
+
+ override def getCallbackByTransactionRequestId(transactionRequestId: String): Box[UtilityPaymentCallbackTrait] =
+ UtilityPaymentCallback.find(By(UtilityPaymentCallback.TransactionRequestId, transactionRequestId))
+
+ override def recordAttempt(
+ callbackId: String,
+ status: String,
+ responseCode: Option[String]
+ ): Box[UtilityPaymentCallbackTrait] =
+ UtilityPaymentCallback.find(By(UtilityPaymentCallback.CallbackId, callbackId)).map { row =>
+ row
+ .Status(status)
+ .Attempts(row.Attempts.get + 1)
+ .ResponseCode(responseCode.getOrElse(""))
+ .LastAttemptDate(new java.util.Date())
+ .saveMe()
+ }
+}
+
+class UtilityPaymentCallback extends UtilityPaymentCallbackTrait with LongKeyedMapper[UtilityPaymentCallback] with IdPK {
+ def getSingleton = UtilityPaymentCallback
+
+ object CallbackId extends MappedString(this, 64)
+ object TransactionRequestId extends MappedString(this, 64)
+ object CallbackUrl extends MappedString(this, 2048)
+ object IdentifierType extends MappedString(this, 64)
+ object Identifier extends MappedString(this, 255)
+ object FromBankId extends MappedString(this, 255)
+ object FromAccountId extends MappedString(this, 255)
+ object CreatedByUserId extends MappedString(this, 255)
+ object Status extends MappedString(this, 32)
+ object Attempts extends MappedInt(this)
+ object ResponseCode extends MappedString(this, 32)
+ object CreationDate extends MappedDateTime(this) {
+ override def defaultValue = new java.util.Date()
+ }
+ object LastAttemptDate extends MappedDateTime(this)
+
+ private def opt(s: String): Option[String] =
+ if (s == null || s.isEmpty) None else Some(s)
+
+ override def callbackId: String = CallbackId.get
+ override def transactionRequestId: String = TransactionRequestId.get
+ override def callbackUrl: String = CallbackUrl.get
+ override def identifierType: String = IdentifierType.get
+ override def identifier: String = Identifier.get
+ override def fromBankId: String = FromBankId.get
+ override def fromAccountId: String = FromAccountId.get
+ override def createdByUserId: String = CreatedByUserId.get
+ override def status: String = Status.get
+ override def attempts: Int = Attempts.get
+ override def responseCode: Option[String] = opt(ResponseCode.get)
+ override def createdAt: java.util.Date = CreationDate.get
+ override def lastAttemptAt: Option[java.util.Date] = Option(LastAttemptDate.get)
+}
+
+object UtilityPaymentCallback extends UtilityPaymentCallback with LongKeyedMetaMapper[UtilityPaymentCallback] {
+ override def dbTableName = "UtilityPaymentCallback"
+ override def dbIndexes = UniqueIndex(CallbackId) :: Index(TransactionRequestId) :: super.dbIndexes
+}
diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala
deleted file mode 100644
index e9ff0c58e5..0000000000
--- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala
+++ /dev/null
@@ -1,105 +0,0 @@
-package code.api
-
-import cats.effect.IO
-import cats.effect.unsafe.implicits.global
-import code.api.util.ErrorMessages
-import code.setup.ServerSetup
-import org.http4s.{Method, Request, Uri}
-
-/**
- * Pure route test for the native http4s OpenID Connect callback
- * (`Http4sOpenIdConnect`). No live provider, no TCP, no DB — drives the routes
- * in-process and flips the gating Props via [[PropsReset]].
- *
- * Pins the gates and path matching that the OBP-OIDC / Keycloak integration
- * depends on:
- * - `openid_connect.enabled=false` (default) → the callback paths do not match
- * and fall through (None), exactly as before the migration.
- * - `openid_connect.enabled=true` → the three callback paths match GET and POST.
- * - `allow_openid_connect=false` → 401 OpenIDConnectIsDisabled.
- * - the session-state gate and the token-exchange failure both surface as 401.
- *
- * The success path (200 {token}) needs a real provider to mint the OIDC tokens,
- * so it is covered by the manual end-to-end verification, not here.
- */
-class Http4sOpenIdConnectRoutesTest extends ServerSetup {
-
- private def run(req: Request[IO]): Option[(Int, String)] =
- Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp =>
- val body = new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8")
- (resp.status.code, body)
- }
-
- private def get(path: String): Request[IO] = Request[IO](Method.GET, Uri.unsafeFromString(path))
- private def post(path: String): Request[IO] = Request[IO](Method.POST, Uri.unsafeFromString(path))
-
- feature("OpenID Connect callback gating (openid_connect.enabled)") {
-
- scenario("Disabled by default — callback paths fall through (None)") {
- Given("openid_connect.enabled is not set (default false)")
- When("the three callback paths are invoked with GET and POST")
- Then("none match — request falls through to the next route (ultimately notFoundCatchAll / JSON 404)")
- run(get("/auth/openid-connect/callback")) shouldBe None
- run(post("/auth/openid-connect/callback")) shouldBe None
- run(get("/auth/openid-connect/callback-1")) shouldBe None
- run(post("/auth/openid-connect/callback-2")) shouldBe None
- }
-
- scenario("Enabled — the three callback paths match GET and POST") {
- Given("openid_connect.enabled=true and the session-state check disabled (no portal)")
- setPropsValues(
- "openid_connect.enabled" -> "true",
- "openid_connect.check_session_state" -> "false"
- )
- When("the three callback paths are invoked with GET and POST (no provider configured)")
- Then("each matches and yields a 401 token-exchange failure (not None)")
- // No provider props → exchangeAuthorizationCodeForTokens fails → 401 CouldNotExchange...
- List(
- get("/auth/openid-connect/callback"),
- post("/auth/openid-connect/callback"),
- get("/auth/openid-connect/callback-1"),
- post("/auth/openid-connect/callback-1"),
- get("/auth/openid-connect/callback-2"),
- post("/auth/openid-connect/callback-2")
- ).foreach { req =>
- val (code, body) = run(req).getOrElse(fail(s"route did not match ${req.method} ${req.uri}"))
- code shouldBe 401
- body should include(ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens)
- }
- }
-
- scenario("Enabled but allow_openid_connect=false → 401 OpenIDConnectIsDisabled") {
- Given("openid_connect.enabled=true but allow_openid_connect=false")
- setPropsValues(
- "openid_connect.enabled" -> "true",
- "allow_openid_connect" -> "false"
- )
- When("the callback is invoked")
- val (code, body) = run(post("/auth/openid-connect/callback"))
- .getOrElse(fail("route did not match"))
- Then("it returns 401 OpenIDConnectIsDisabled before any token exchange")
- code shouldBe 401
- body should include(ErrorMessages.OpenIDConnectIsDisabled)
- }
-
- scenario("Enabled, default session-state check, non-matching state → 401 InvalidOpenIDConnectState") {
- Given("openid_connect.enabled=true with the session-state check left at its default (true)")
- setPropsValues("openid_connect.enabled" -> "true")
- When("a callback arrives whose state does not equal the (empty) session state")
- val (code, body) = run(get("/auth/openid-connect/callback?code=abc&state=non-empty"))
- .getOrElse(fail("route did not match"))
- Then("it returns 401 InvalidOpenIDConnectState before any token exchange")
- code shouldBe 401
- body should include(ErrorMessages.InvalidOpenIDConnectState)
- }
-
- scenario("Enabled — an unrelated /auth/openid-connect path does not match") {
- Given("openid_connect.enabled=true")
- setPropsValues("openid_connect.enabled" -> "true")
- When("a path that is not one of the three callbacks is invoked")
- Then("the route does not match")
- run(get("/auth/openid-connect/callback-3")) shouldBe None
- run(get("/auth/openid-connect/other")) shouldBe None
- }
- }
-}
diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala
deleted file mode 100644
index 2df62e119a..0000000000
--- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala
+++ /dev/null
@@ -1,133 +0,0 @@
-package code.api
-
-import cats.effect.IO
-import cats.effect.unsafe.implicits.global
-import code.setup.ServerSetup
-import code.users.Users
-import com.comcast.ip4s._
-import com.nimbusds.jose.crypto.RSASSASigner
-import com.nimbusds.jose.jwk.JWKSet
-import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
-import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
-import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
-import org.http4s.dsl.io._
-import org.http4s.ember.server.EmberServerBuilder
-import org.http4s.implicits._
-import org.http4s.{HttpRoutes, Method, Request, Uri}
-
-import java.util.Date
-
-/**
- * Success-path integration test for [[Http4sOpenIdConnect]] — the `200 {"token": ...}`
- * branch that the routing/failure suite ([[Http4sOpenIdConnectRoutesTest]]) cannot reach
- * because it needs a provider to mint the OIDC tokens.
- *
- * Self-contained, no live provider: stands up a local stub OIDC provider (Ember) that
- * serves
- * - `POST /token` → a token response whose `id_token` is a locally-signed RS256 JWT
- * - `GET /jwks` → the matching public JWK set
- * points the `openid_connect_1.*` props at it, then drives the callback route in-process.
- * It asserts the handler exchanges the code, validates the JWT against the JWKS,
- * provisions the resource user, and returns `200 {"token": ...}`.
- *
- * Why a freshly-signed token is enough: `JwtUtil.validateIdToken` reads `iss`/`aud` from
- * the token itself and only enforces the signature (against the served JWKS) and expiry,
- * so no configured iss/aud matching is required. The JWS header `kid` matches the served
- * JWK so the verification key selector picks the right key.
- *
- * This also exercises the M1 change — the provisioning block is now wrapped in
- * `DB.use(DefaultConnectionIdentifier)` (one connection for all OIDC writes).
- */
-class Http4sOpenIdConnectSuccessTest extends ServerSetup {
-
- private val providerClaim = "http://127.0.0.1/oidc-test-provider"
- private val preferredUser = "oidctestuser"
- private val clientId = "obp-oidc-test-client"
-
- // RSA keypair used to sign the id_token; its public half is served at /jwks.
- private val rsaJwk = new RSAKeyGenerator(2048).keyID("oidc-test-kid").generate()
-
- private def signedIdToken(issuer: String): String = {
- val claims = new JWTClaimsSet.Builder()
- .issuer(issuer)
- .subject("oidc-test-subject")
- .audience(clientId)
- .expirationTime(new Date(System.currentTimeMillis() + 3600L * 1000))
- .issueTime(new Date())
- .claim("preferred_username", preferredUser)
- .claim("email", "oidctest@example.com")
- .claim("provider", providerClaim)
- .claim("azp", clientId)
- .build()
- val jwt = new SignedJWT(
- new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(),
- claims
- )
- jwt.sign(new RSASSASigner(rsaJwk))
- jwt.serialize()
- }
-
- /** Allocate an ephemeral free port for the stub provider. */
- private def freePort(): Int = {
- val socket = new java.net.ServerSocket(0)
- try socket.getLocalPort finally socket.close()
- }
-
- private def stubProvider(issuer: String): HttpRoutes[IO] = HttpRoutes.of[IO] {
- case POST -> Root / "token" =>
- Ok(s"""{"id_token":"${signedIdToken(issuer)}","access_token":"access-xyz",""" +
- s""""token_type":"Bearer","expires_in":"3600","refresh_token":"refresh-xyz","scope":"openid"}""")
- case GET -> Root / "jwks" =>
- Ok(new JWKSet(rsaJwk.toPublicJWK).toString)
- }
-
- private def run(req: Request[IO]): (Int, String) =
- Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp =>
- (resp.status.code, new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8"))
- }.getOrElse(fail(s"route did not match ${req.method} ${req.uri}"))
-
- feature("OpenID Connect callback — success path") {
-
- scenario("valid code + signed id_token → 200 {token} and the user is provisioned") {
- val port = freePort()
- val portObj = Port.fromInt(port).getOrElse(fail(s"invalid free port $port"))
- val issuer = s"http://127.0.0.1:$port"
-
- val server = EmberServerBuilder
- .default[IO]
- .withHost(ipv4"127.0.0.1")
- .withPort(portObj)
- .withHttpApp(stubProvider(issuer).orNotFound)
- .build
-
- server.use { _ =>
- IO {
- Given("a local stub OIDC provider and openid_connect_1.* pointed at it")
- setPropsValues(
- "openid_connect.enabled" -> "true",
- "openid_connect.check_session_state" -> "false",
- "allow_openid_connect" -> "true",
- "openid_connect_1.client_id" -> clientId,
- "openid_connect_1.client_secret" -> "test-secret",
- "openid_connect_1.callback_url" -> "http://localhost/auth/openid-connect/callback",
- "openid_connect_1.endpoint.token" -> s"$issuer/token",
- "openid_connect_1.endpoint.jwks_uri" -> s"$issuer/jwks"
- )
-
- When("the provider redirects back to the callback with an authorization code")
- val (code, body) = run(
- Request[IO](Method.GET,
- Uri.unsafeFromString("/auth/openid-connect/callback?code=auth-code-123&state=ignored"))
- )
-
- Then("the handler returns 200 with a minted OBP DirectLogin token")
- code shouldBe 200
- body should include("token")
-
- And("the resource user was provisioned from the validated claims")
- Users.users.vend.getUserByProviderId(providerClaim, preferredUser).isDefined shouldBe true
- }
- }.unsafeRunSync()
- }
- }
-}
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
index b9047631eb..40558eed03 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
@@ -7,7 +7,8 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID
import code.api.ResponseHeader
import code.api.util.APIUtil
import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme}
-import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId}
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier}
+import code.utilitypayment.UtilityPaymentCallbacks
import code.api.Constant.SYSTEM_AUDITOR_VIEW_ID
import code.views.MapperViews
import code.views.system.ViewPermission
@@ -1240,14 +1241,14 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
createTestRoutingScheme(scheme)
val headers = Map("DirectLogin" -> s"token=${token1.value}")
- val body = """{"enabled":true,"bank_notes":"Routed via Gateway X. Cutoff 22:00."}"""
+ val body = """{"enabled":true,"bank_notes":"Routed via the payment gateway. Cutoff 22:00."}"""
val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/$scheme", body, headers)
statusCode shouldBe 200
json match {
case JObject(fields) =>
val map = toFieldMap(fields)
map.get("scheme") shouldBe Some(JString(scheme))
- map.get("bank_notes") shouldBe Some(JString("Routed via Gateway X. Cutoff 22:00."))
+ map.get("bank_notes") shouldBe Some(JString("Routed via the payment gateway. Cutoff 22:00."))
case _ => fail("Expected JSON object")
}
}
@@ -1616,6 +1617,152 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
}
}
+ // ─── UTILITY transaction request ──────────────────────────────────────────
+
+ /** Seed a UTILITY/BILL-category routing scheme plus a destination account_routing
+ * so the biller resolves (mirrors seedPayeeForLookup but with a non-ACCOUNT category). */
+ private def seedUtilityBiller(prefix: String, category: String, address: String, destBankId: String, destAccountId: String): String = {
+ val scheme = freshSchemeName(prefix)
+ RoutingSchemes.routingScheme.vend.createRoutingScheme(
+ scheme = scheme, country = "TZ", category = category,
+ addressPattern = "^[0-9]+$", secondaryAddressPattern = None,
+ exampleAddress = address, description = "Test biller", downstreamRails = Nil,
+ status = "ACTIVE", createdByUserId = resourceUser1.userId
+ )
+ BankAccountRouting.create
+ .BankId(destBankId)
+ .AccountId(destAccountId)
+ .AccountRoutingScheme(scheme)
+ .AccountRoutingAddress(address)
+ .saveMe()
+ scheme
+ }
+
+ feature("Http4s700 createTransactionRequestUtility endpoint") {
+
+ scenario("Reject unauthenticated POST", Http4s700RoutesTag) {
+ val bankId = testBankId1.value
+ val accountId = testAccountId0.value
+ val body = """{"to":{"scheme":"TZ.UTILITY_METER","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}"""
+ val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body)
+ statusCode shouldBe 401
+ }
+
+ scenario("Return 400 when identifier scheme is not registered", Http4s700RoutesTag) {
+ val bankId = testBankId1.value
+ val accountId = testAccountId0.value
+ val body = """{"to":{"scheme":"TZ.UNKNOWN_BILLER","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}"""
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers)
+ statusCode shouldBe 400
+ json match {
+ case JObject(fields) =>
+ toFieldMap(fields).get("message") match {
+ case Some(JString(msg)) => msg should include(PayeeLookupIdentifierTypeNotRegistered)
+ case _ => fail("Expected message field")
+ }
+ case _ => fail("Expected JSON object")
+ }
+ }
+
+ scenario("Return 400 when identifier scheme category is not UTILITY or BILL", Http4s700RoutesTag) {
+ val bankId = testBankId1.value
+ val accountId = testAccountId0.value
+ // Register an ACCOUNT-category scheme — valid pattern, wrong category for a UTILITY payment.
+ val scheme = freshSchemeName("ACAT")
+ RoutingSchemes.routingScheme.vend.createRoutingScheme(
+ scheme = scheme, country = "TZ", category = "ACCOUNT",
+ addressPattern = "^[0-9]+$", secondaryAddressPattern = None,
+ exampleAddress = "24730238417", description = "Account scheme",
+ downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId
+ )
+ val body = s"""{"to":{"scheme":"$scheme","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}"""
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers)
+ statusCode shouldBe 400
+ json match {
+ case JObject(fields) =>
+ toFieldMap(fields).get("message") match {
+ case Some(JString(msg)) => msg should include(UtilityIdentifierTypeWrongCategory)
+ case _ => fail("Expected message field")
+ }
+ case _ => fail("Expected JSON object")
+ }
+ }
+
+ scenario("Return 400 when identifier value does not match the scheme's address_pattern", Http4s700RoutesTag) {
+ val bankId = testBankId1.value
+ val accountId = testAccountId0.value
+ // UTILITY-category scheme with a strict numeric pattern; send a non-numeric value.
+ val scheme = freshSchemeName("USTR")
+ RoutingSchemes.routingScheme.vend.createRoutingScheme(
+ scheme = scheme, country = "TZ", category = "UTILITY",
+ addressPattern = "^[0-9]{8,14}$", secondaryAddressPattern = None,
+ exampleAddress = "24730238417", description = "Strict meter",
+ downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId
+ )
+ val body = s"""{"to":{"scheme":"$scheme","value":"not-a-meter"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}"""
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers)
+ statusCode shouldBe 400
+ json match {
+ case JObject(fields) =>
+ toFieldMap(fields).get("message") match {
+ case Some(JString(msg)) => msg should include(UtilityInvalidIdentifier)
+ case _ => fail("Expected message field")
+ }
+ case _ => fail("Expected JSON object")
+ }
+ }
+
+ scenario("Return 201 with a registered callback when the biller resolves", Http4s700RoutesTag) {
+ val bankId = testBankId1.value
+ val accountId = testAccountId0.value
+ val acctCurrency = code.bankconnectors.Connector.connector.vend
+ .getBankAccountLegacy(testBankId1, testAccountId0, None)
+ .map(_._1.currency).openOrThrowException("test account")
+
+ val meter = s"247${(System.currentTimeMillis() % 100000000L).toString.reverse.padTo(8, '0').reverse}"
+ val scheme = seedUtilityBiller("UTIL", "UTILITY", meter, bankId, accountId)
+
+ val body =
+ s"""{
+ | "to": {"scheme":"$scheme","value":"$meter"},
+ | "value": {"currency":"$acctCurrency","amount":"1000"},
+ | "description": "utility token purchase",
+ | "client_reference": "ref-0001",
+ | "payer": {"phone":"255700000000","name":"Jane Doe","email":"jane.doe@example.com"},
+ | "callback_url": "https://example.com/utility/callback"
+ |}""".stripMargin
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers)
+ statusCode shouldBe 201
+ val trId = json match {
+ case JObject(fields) =>
+ val map = toFieldMap(fields)
+ map.keys should contain allOf ("id", "type", "from", "details", "status", "callback")
+ map.get("type") shouldBe Some(JString("UTILITY"))
+ map.get("callback") match {
+ case Some(JObject(cbFields)) =>
+ val cb = toFieldMap(cbFields)
+ cb.get("callback_url") shouldBe Some(JString("https://example.com/utility/callback"))
+ cb.keys should contain allOf ("callback_id", "status")
+ case other => fail(s"Expected callback object, got: $other")
+ }
+ map.get("id") match {
+ case Some(JString(id)) => id
+ case _ => fail("Expected id as JSON string")
+ }
+ case _ => fail("Expected JSON object")
+ }
+
+ // The one-shot callback row was persisted against this transaction request.
+ val stored = UtilityPaymentCallbacks.utilityPaymentCallback.vend.getCallbackByTransactionRequestId(trId)
+ stored.isDefined shouldBe true
+ stored.openOrThrowException("callback row").callbackUrl shouldBe "https://example.com/utility/callback"
+ }
+ }
+
// ─── factoryResetSystemView ───────────────────────────────────────────────
feature("Http4s700 factoryResetSystemView endpoint") {
diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala
index 08677166b7..5962a9af1d 100644
--- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala
+++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala
@@ -127,6 +127,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{
object MOBILE_WALLET extends Value
object BULK extends Value
object OPEN_CORRIDOR extends Value
+ object UTILITY extends Value
}
sealed trait StrongCustomerAuthentication extends EnumValue