From cdf3c5ad4bca4cffb5899a034843cb66d4e412ba Mon Sep 17 00:00:00 2001 From: Sina Madani Date: Thu, 8 Aug 2024 17:01:38 +0100 Subject: [PATCH] feat: Add Subaccounts API (#7) --- CHANGELOG.md | 5 + README.md | 1 + .../com/vonage/client/kt/Subaccounts.kt | 72 ++++ .../kotlin/com/vonage/client/kt/Verify.kt | 2 +- .../kotlin/com/vonage/client/kt/Vonage.kt | 1 + .../com/vonage/client/kt/AbstractTest.kt | 4 +- .../com/vonage/client/kt/AccountTest.kt | 1 - .../com/vonage/client/kt/NumbersTest.kt | 1 - .../com/vonage/client/kt/SubaccountsTest.kt | 362 ++++++++++++++++++ 9 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/vonage/client/kt/Subaccounts.kt create mode 100644 src/test/kotlin/com/vonage/client/kt/SubaccountsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f5233..3d3d5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.8.0] - 2024-08-?? + +### Added +- Subaccounts API + ## [0.7.0] - 2024-08-06 ### Added diff --git a/README.md b/README.md index f7e4b70..7f5a336 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ You'll need to have [created a Vonage account](https://dashboard.nexmo.com/sign- - [Redact](https://developer.vonage.com/en/redact/overview) - [SIM Swap](https://developer.vonage.com/en/sim-swap/overview) - [SMS](https://developer.vonage.com/en/messaging/sms/overview) +- [Subaccounts](https://developer.vonage.com/en/account/subaccounts/overview) - [Verify](https://developer.vonage.com/en/verify/overview) - [Voice](https://developer.vonage.com/en/voice/voice-api/overview) diff --git a/src/main/kotlin/com/vonage/client/kt/Subaccounts.kt b/src/main/kotlin/com/vonage/client/kt/Subaccounts.kt new file mode 100644 index 0000000..9899947 --- /dev/null +++ b/src/main/kotlin/com/vonage/client/kt/Subaccounts.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.subaccounts.* +import com.vonage.client.subaccounts.Account +import java.time.Instant + +class Subaccounts internal constructor(private val client: SubaccountsClient) { + + fun listSubaccounts(): ListSubaccountsResponse = client.listSubaccounts() + + fun createSubaccount(name: String, secret: String? = null, usePrimaryAccountBalance: Boolean? = null): Account { + val builder = CreateSubaccountRequest.builder().name(name).secret(secret) + if (usePrimaryAccountBalance != null) { + builder.usePrimaryAccountBalance(usePrimaryAccountBalance) + } + return client.createSubaccount(builder.build()) + } + + fun getSubaccount(subaccountKey: String): Account = client.getSubaccount(subaccountKey) + + fun updateSubaccount(subaccountKey: String, name: String? = null, + usePrimaryAccountBalance: Boolean? = null, suspend: Boolean? = null): Account { + val builder = UpdateSubaccountRequest.builder(subaccountKey) + if (name != null) { + builder.name(name) + } + if (usePrimaryAccountBalance != null) { + builder.usePrimaryAccountBalance(usePrimaryAccountBalance) + } + if (suspend != null) { + builder.suspended(suspend) + } + return client.updateSubaccount(builder.build()) + } + + fun listCreditTransfers(startDate: Instant? = null, endDate: Instant? = null, + subaccount: String? = null): List = + client.listCreditTransfers(ListTransfersFilter.builder() + .startDate(startDate).endDate(endDate).subaccount(subaccount).build() + ) + + fun listBalanceTransfers(startDate: Instant? = null, endDate: Instant? = null, + subaccount: String? = null): List = + client.listBalanceTransfers(ListTransfersFilter.builder() + .startDate(startDate).endDate(endDate).subaccount(subaccount).build() + ) + + fun transferCredit(from: String, to: String, amount: Double, ref: String? = null): MoneyTransfer = + client.transferCredit(MoneyTransfer.builder().from(from).to(to).amount(amount).reference(ref).build()) + + fun transferBalance(from: String, to: String, amount: Double, ref: String? = null): MoneyTransfer = + client.transferBalance(MoneyTransfer.builder().from(from).to(to).amount(amount).reference(ref).build()) + + fun transferNumber(from: String, to: String, number: String, country: String): NumberTransfer = + client.transferNumber(NumberTransfer.builder().from(from).to(to).number(number).country(country).build()) + +} diff --git a/src/main/kotlin/com/vonage/client/kt/Verify.kt b/src/main/kotlin/com/vonage/client/kt/Verify.kt index c6f86a3..ca1edde 100644 --- a/src/main/kotlin/com/vonage/client/kt/Verify.kt +++ b/src/main/kotlin/com/vonage/client/kt/Verify.kt @@ -27,7 +27,7 @@ class Verify(private val client: Verify2Client) { VerificationRequest.builder().brand(brand).apply(init).build() ) - inner class ExistingRequest internal constructor(private val requestId: UUID) { + inner class ExistingRequest internal constructor(val requestId: UUID) { fun cancel(): Unit = client.cancelVerification(requestId) diff --git a/src/main/kotlin/com/vonage/client/kt/Vonage.kt b/src/main/kotlin/com/vonage/client/kt/Vonage.kt index fbccce0..37efcb4 100644 --- a/src/main/kotlin/com/vonage/client/kt/Vonage.kt +++ b/src/main/kotlin/com/vonage/client/kt/Vonage.kt @@ -29,6 +29,7 @@ class Vonage(init: VonageClient.Builder.() -> Unit) { val redact = Redact(client.redactClient) val simSwap = SimSwap(client.simSwapClient) val sms = Sms(client.smsClient) + val subaccounts = Subaccounts(client.subaccountsClient) val verify = Verify(client.verify2Client) val verifyLegacy = VerifyLegacy(client.verifyClient) val voice = Voice(client.voiceClient) diff --git a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt index 745a8b4..f23f7e1 100644 --- a/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AbstractTest.kt @@ -55,6 +55,8 @@ abstract class AbstractTest { protected val altNumber = "447700900001" protected val brand = "Nexmo KT" protected val text = "Hello, World!" + protected val country = "GB" + protected val secret = "ABCDEFGH01234abc" protected val sipUri = "sip:rebekka@sip.example.com" protected val clientRef = "my-personal-reference" protected val textHexEncoded = "48656c6c6f2c20576f726c6421" @@ -242,7 +244,7 @@ abstract class AbstractTest { protected fun mockPatch(expectedUrl: String, expectedRequestParams: Map? = null, status: Int = 200, contentType: ContentType? = ContentType.APPLICATION_JSON, authType: AuthType? = AuthType.JWT, expectedResponseParams: Map? = null) = - mockP(HttpMethod.PUT, expectedUrl, expectedRequestParams, status, authType, contentType, expectedResponseParams) + mockP(HttpMethod.PATCH, expectedUrl, expectedRequestParams, status, authType, contentType, expectedResponseParams) protected fun mockDelete(expectedUrl: String, authType: AuthType? = null, expectedResponseParams: Map? = null) = diff --git a/src/test/kotlin/com/vonage/client/kt/AccountTest.kt b/src/test/kotlin/com/vonage/client/kt/AccountTest.kt index f3ca5bd..ea08ecf 100644 --- a/src/test/kotlin/com/vonage/client/kt/AccountTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/AccountTest.kt @@ -24,7 +24,6 @@ class AccountTest : AbstractTest() { private val account = vonage.account private val authType = AuthType.API_KEY_SECRET_HEADER private val secretId = "ad6dc56f-07b5-46e1-a527-85530e625800" - private val secret = "ABCDEFGH01234abc" private val trx = "8ef2447e69604f642ae59363aa5f781b" private val baseUrl = "/account" private val secretsUrl = "${baseUrl}s/$apiKey/secrets" diff --git a/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt index 4e96d41..31d7253 100644 --- a/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt +++ b/src/test/kotlin/com/vonage/client/kt/NumbersTest.kt @@ -24,7 +24,6 @@ import kotlin.test.* class NumbersTest : AbstractTest() { private val client = vonage.numbers private val authType = AuthType.API_KEY_SECRET_HEADER - private val country = "GB" private val targetApiKey = "1a2345b7" private val moSmppSysType = "inbound" private val buyEndpoint = "buy" diff --git a/src/test/kotlin/com/vonage/client/kt/SubaccountsTest.kt b/src/test/kotlin/com/vonage/client/kt/SubaccountsTest.kt new file mode 100644 index 0000000..4133065 --- /dev/null +++ b/src/test/kotlin/com/vonage/client/kt/SubaccountsTest.kt @@ -0,0 +1,362 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.kt + +import com.vonage.client.common.HttpMethod +import kotlin.test.* +import com.vonage.client.subaccounts.* +import com.vonage.client.subaccounts.Account +import java.math.BigDecimal +import java.time.temporal.ChronoUnit + +class SubaccountsTest : AbstractTest() { + private val client = vonage.subaccounts + private val baseUrl = "/accounts/$apiKey" + private val subaccountsUrl = "$baseUrl/subaccounts" + private val existingSubUrl = "$subaccountsUrl/$apiKey2" + private val authType = AuthType.API_KEY_SECRET_HEADER + private val name = "Subaccount department A" + private val primaryName = "Primary Account" + private val balance = 93.26 + private val usePrimary = false + private val suspended = true + private val creditLimit = -100.0 + private val amount = 48.75 + private val transferId = "07b5-46e1-a527-85530e625800" + private val reference = "This gets added to the audit log" + private val transferFrom = apiKey + private val transferTo = apiKey2 + private val sampleSubaccountMap = mapOf( + "secret" to secret, + "api_key" to apiKey2, + "name" to name, + "primary_account_api_key" to apiKey, + "use_primary_account_balance" to usePrimary, + "created_at" to timestampStr, + "suspended" to suspended, + "balance" to balance, + "credit_limit" to creditLimit + ) + + private fun assertEqualsSampleSubaccount(parsed: Account) { + assertNotNull(parsed) + assertEquals(secret, parsed.secret) + assertEquals(apiKey2, parsed.apiKey) + assertEquals(name, parsed.name) + assertEquals(apiKey, parsed.primaryAccountApiKey) + assertEquals(usePrimary, parsed.usePrimaryAccountBalance) + assertEquals(timestamp, parsed.createdAt) + assertEquals(suspended, parsed.suspended) + assertEquals(BigDecimal.valueOf(balance), parsed.balance) + assertEquals(BigDecimal.valueOf(creditLimit), parsed.creditLimit) + } + + private fun assertCreateSubaccount(additionalParams: Map, invocation: Subaccounts.() -> Account) { + mockPost( + expectedUrl = subaccountsUrl, authType = authType, + expectedRequestParams = mapOf("name" to name) + additionalParams, + expectedResponseParams = sampleSubaccountMap + ) + assertEqualsSampleSubaccount(invocation.invoke(client)) + assert401ApiResponseException(subaccountsUrl, HttpMethod.POST) { + invocation.invoke(client) + } + } + + private enum class TransferType { + CREDIT, BALANCE; + + @Override + override fun toString(): String { + return name.lowercase() + } + } + + private fun TransferType.getTransferUrl(): String = "$baseUrl/${toString()}-transfers" + + private fun assertTransfer(type: TransferType, includeRef: Boolean, invocation: Subaccounts.() -> MoneyTransfer) { + val url = type.getTransferUrl() + val baseTransferParams = mapOf( + "amount" to amount, + "from" to transferFrom, + "to" to transferTo + ) + + mockPost( + expectedUrl = url, authType = authType, + expectedRequestParams = baseTransferParams + + if (includeRef) mapOf("reference" to reference) else mapOf(), + expectedResponseParams = mapOf( + "${type}_transfer_id" to transferId, + "amount" to amount, + "from" to transferFrom, + "to" to transferTo, + "reference" to reference, + "created_at" to timestampStr + ) + ) + + val response = invocation.invoke(client) + assertNotNull(response) + assertEquals(BigDecimal.valueOf(amount), response.amount) + assertEquals(transferFrom, response.from) + assertEquals(transferTo, response.to) + assertEquals(reference, response.reference) + assertEquals(timestamp, response.createdAt) + assertEquals(reference, response.reference) + + assert401ApiResponseException(url, HttpMethod.POST) { + invocation.invoke(client) + } + } + + private fun assertListTransfers(type: TransferType, filters: Boolean, invocation: Subaccounts.() -> List) { + val url = type.getTransferUrl() + mockGet( + expectedUrl = url, authType = authType, + expectedQueryParams = if (filters) mapOf( + "start_date" to timestamp.truncatedTo(ChronoUnit.SECONDS), + "end_date" to timestamp2.truncatedTo(ChronoUnit.SECONDS), + "subaccount" to apiKey2 + ) else mapOf(), + expectedResponseParams = mapOf( + "_embedded" to mapOf( + "${type}_transfers" to listOf( + mapOf( + "${type}_transfer_id" to transferId, + "amount" to amount, + "from" to transferFrom, + "to" to transferTo, + "reference" to reference, + "created_at" to timestampStr + ), + mapOf() + ) + ) + ) + ) + val response = invocation.invoke(client) + assertNotNull(response) + assertEquals(2, response.size) + val main = response[0] + assertNotNull(main) + assertEquals(BigDecimal.valueOf(amount), main.amount) + assertEquals(transferFrom, main.from) + assertEquals(transferTo, main.to) + assertEquals(reference, main.reference) + assertEquals(timestamp, main.createdAt) + val blank = response[1] + assertNotNull(blank) + assertNull(blank.amount) + assertNull(blank.from) + assertNull(blank.to) + assertNull(blank.reference) + assertNull(blank.createdAt) + + assert401ApiResponseException(url, HttpMethod.GET) { + invocation.invoke(client) + } + } + + @Test + fun `list subaccounts`() { + val primaryBalance = 350.10 + val primaryCreditLimit = 123.45 + mockGet( + expectedUrl = subaccountsUrl, authType = authType, + expectedResponseParams = mapOf( + "_embedded" to mapOf( + "primary_account" to mapOf( + "api_key" to apiKey, + "name" to primaryName, + "primary_account_api_key" to apiKey, + "use_primary_account_balance" to true, + "created_at" to timestamp2Str, + "suspended" to false, + "balance" to primaryBalance, + "credit_limit" to primaryCreditLimit + ), + "subaccounts" to listOf>( + sampleSubaccountMap, + mapOf() + ) + ) + ) + ) + val response = client.listSubaccounts() + assertNotNull(response) + val primary = response.primaryAccount + assertNotNull(primary) + assertEquals(apiKey, primary.apiKey) + assertEquals(primaryName, primary.name) + assertEquals(apiKey, primary.primaryAccountApiKey) + assertTrue(primary.usePrimaryAccountBalance) + assertEquals(timestamp2, primary.createdAt) + assertFalse(primary.suspended) + assertEquals(BigDecimal.valueOf(primaryBalance), primary.balance) + assertEquals(BigDecimal.valueOf(primaryCreditLimit), primary.creditLimit) + + val subaccounts = response.subaccounts + assertEquals(2, subaccounts.size) + assertEqualsSampleSubaccount(subaccounts[0]) + val blank = subaccounts[1] + assertNotNull(blank) + assertNull(blank.secret) + assertNull(blank.apiKey) + assertNull(blank.createdAt) + assertNull(blank.suspended) + assertNull(blank.balance) + assertNull(blank.creditLimit) + assert401ApiResponseException(subaccountsUrl, HttpMethod.GET) { + client.listSubaccounts() + } + } + + @Test + fun `create subaccount required parameters`() { + assertCreateSubaccount(mapOf()) { createSubaccount(name) } + } + + @Test + fun `create subaccount all parameters`() { + assertCreateSubaccount(mapOf( + "secret" to secret, + "use_primary_account_balance" to usePrimary + )) { + createSubaccount(name, secret, usePrimary) + } + } + + @Test + fun `get subaccount`() { + mockGet( + expectedUrl = existingSubUrl, authType = authType, + expectedResponseParams = sampleSubaccountMap + ) + assertEqualsSampleSubaccount(client.getSubaccount(apiKey2)) + assert401ApiResponseException("$subaccountsUrl/$apiKey2", HttpMethod.GET) { + client.getSubaccount(apiKey2) + } + } + + @Test + fun `update subaccount all parameters`() { + mockPatch( + expectedUrl = existingSubUrl, authType = authType, + expectedRequestParams = mapOf( + "name" to name, + "use_primary_account_balance" to usePrimary, + "suspended" to suspended + ), + expectedResponseParams = sampleSubaccountMap + ) + assertEqualsSampleSubaccount(client.updateSubaccount(apiKey2, name, usePrimary, suspended)) + assert401ApiResponseException(existingSubUrl, HttpMethod.PATCH) { + client.updateSubaccount(apiKey2, suspend = suspended) + } + } + + @Test + fun `update subaccount name only`() { + mockPatch( + expectedUrl = existingSubUrl, authType = authType, + expectedRequestParams = mapOf("name" to name), + expectedResponseParams = sampleSubaccountMap + ) + assertEqualsSampleSubaccount(client.updateSubaccount(subaccountKey = apiKey2, name = name)) + } + + @Test + fun `list credit transfers no parameters`() { + assertListTransfers(TransferType.CREDIT, false) { + listCreditTransfers() + } + } + + @Test + fun `list credit transfers all parameters`() { + assertListTransfers(TransferType.CREDIT, true) { + listCreditTransfers(timestamp, timestamp2, apiKey2) + } + } + + @Test + fun `list balance transfers no parameters`() { + assertListTransfers(TransferType.BALANCE, false) { + listBalanceTransfers() + } + } + + @Test + fun `list balance transfers all parameters`() { + assertListTransfers(TransferType.BALANCE, true) { + listBalanceTransfers(timestamp, timestamp2, apiKey2) + } + } + + @Test + fun `transfer credit no reference`() { + assertTransfer(TransferType.CREDIT, false) { + transferCredit(transferFrom, transferTo, amount) + } + } + + @Test + fun `transfer credit with reference`() { + assertTransfer(TransferType.CREDIT, true) { + transferCredit(transferFrom, transferTo, amount, reference) + } + } + + @Test + fun `transfer balance no reference`() { + assertTransfer(TransferType.BALANCE, false) { + transferBalance(transferFrom, transferTo, amount) + } + } + + @Test + fun `transfer balance with reference`() { + assertTransfer(TransferType.BALANCE, true) { + transferBalance(transferFrom, transferTo, amount, reference) + } + } + + @Test + fun `transfer number`() { + val url = "$baseUrl/transfer-number" + val params = mapOf( + "number" to toNumber, + "country" to country, + "from" to transferFrom, + "to" to transferTo + ) + mockPost( + expectedUrl = url, authType = authType, + expectedRequestParams = params, expectedResponseParams = params + ) + val response = client.transferNumber(transferFrom, transferTo, toNumber, country) + assertNotNull(response) + assertEquals(transferFrom, response.from) + assertEquals(transferTo, response.to) + assertEquals(toNumber, response.number) + assertEquals(country, response.country) + + assert401ApiResponseException(url, HttpMethod.POST) { + client.transferNumber(transferFrom, transferTo, toNumber, country) + } + } +} \ No newline at end of file