Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up C2B functionality #133

Merged
merged 8 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions daraja/src/commonMain/kotlin/com/vickbt/darajakmp/Daraja.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.vickbt.darajakmp.utils.DarajaIdentifierType
import com.vickbt.darajakmp.utils.DarajaResult
import com.vickbt.darajakmp.utils.DarajaTransactionCode
import com.vickbt.darajakmp.utils.DarajaTransactionType
import com.vickbt.darajakmp.utils.capitalize
import com.vickbt.darajakmp.utils.getDarajaPassword
import com.vickbt.darajakmp.utils.getDarajaPhoneNumber
import com.vickbt.darajakmp.utils.getDarajaTimestamp
Expand Down Expand Up @@ -304,29 +305,34 @@ class Daraja(
* @param [validationURL] This is the URL that receives the validation request from the API upon payment submission. The validation URL is only called if the external validation on the registered shortcode is enabled. (By default External Validation is disabled).
* @param [responseType] This parameter specifies what is to happen if for any reason the validation URL is not reachable. Note that, this is the default action value that determines what M-PESA will do in the scenario that your endpoint is unreachable or is unable to respond on time. Only two values are allowed: Completed or Cancelled. Completed means M-PESA will automatically complete your transaction, whereas Cancelled means M-PESA will automatically cancel the transaction, in the event M-PESA is unable to reach your Validation URL.
*
* @return [C2BResponse]
* @return [C2BRegistrationResponse]
* */
internal fun c2bRegistration(
businessShortCode: Int,
fun c2bRegistration(
businessShortCode: String,
confirmationURL: String,
validationURL: String? = null,
responseType: C2BResponseType? = C2BResponseType.COMPLETED,
validationURL: String,
responseType: C2BResponseType = C2BResponseType.COMPLETED,
): DarajaResult<C2BResponse> =
runBlocking(Dispatchers.IO) {
val c2BRegistrationRequest =
C2BRegistrationRequest(
confirmationURL = confirmationURL,
validationURL = validationURL,
responseType = responseType?.name?.lowercase(),
responseType =
if (validationURL.isEmpty()) {
C2BResponseType.COMPLETED.name.capitalize()
} else {
responseType.name.capitalize()
},
shortCode = businessShortCode,
)

darajaApiService.c2bRegistration(c2bRegistrationRequest = c2BRegistrationRequest)
}

internal fun c2b(
fun c2b(
amount: Int,
billReferenceNumber: String,
billReferenceNumber: String? = null,
transactionType: DarajaTransactionType,
phoneNumber: String,
businessShortCode: String,
Expand All @@ -335,15 +341,15 @@ class Daraja(
val c2bRequest =
C2BRequest(
amount = amount,
billReferenceNumber = billReferenceNumber,
commandID = transactionType.name,
phoneNumber = phoneNumber.getDarajaPhoneNumber().toLong(),
shortCode =
billReferenceNumber =
if (transactionType.name == DarajaTransactionType.CustomerPayBillOnline.name) {
businessShortCode
} else {
billReferenceNumber
} else {
null
},
commandID = transactionType.name,
phoneNumber = phoneNumber.getDarajaPhoneNumber().toLong(),
shortCode = businessShortCode,
)

darajaApiService.c2b(c2bRequest = c2bRequest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ internal class DarajaApiService(
}.body()
}

internal suspend fun c2bRegistration(c2bRegistrationRequest: C2BRegistrationRequest): DarajaResult<C2BResponse> =
suspend fun c2bRegistration(c2bRegistrationRequest: C2BRegistrationRequest): DarajaResult<C2BResponse> =
darajaSafeApiCall {
val accessToken =
inMemoryCache.get(1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,25 @@

package com.vickbt.darajakmp.network.models

import com.vickbt.darajakmp.utils.C2BResponseType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.native.ObjCName

@ObjCName(swiftName = "C2BRegistrationRequest")
@Serializable
/***/
internal data class C2BRegistrationRequest(
/**This is the URL that receives the confirmation request from API upon payment completion.*/
/**
* @param [confirmationURL] This is the URL that receives the confirmation request from API upon payment completion.
* @param [validationURL] This is the URL that receives the validation request from the API upon payment submission. The validation URL is only called if the external validation on the registered shortcode is enabled. (By default External Validation is disabled).
* @param [responseType] This parameter specifies what is to happen if for any reason the validation URL is not reachable. Note that, this is the default action value that determines what M-PESA will do in the scenario that your endpoint is unreachable or is unable to respond on time. Only two values are allowed: `Completed` or `Cancelled`. `Completed` means M-PESA will automatically complete your transaction, whereas `Cancelled` means M-PESA will automatically cancel the transaction, in the event M-PESA is unable to reach your Validation URL.
* @param [shortCode] A unique number is tagged to an M-PESA pay bill/till number of the organization.
* */
data class C2BRegistrationRequest(
@SerialName("ConfirmationURL")
val confirmationURL: String,
/**This is the URL that receives the validation request from the API upon payment submission. The validation URL is only called if the external validation on the registered shortcode is enabled. (By default External Validation is disabled).*/
@SerialName("ValidationURL")
val validationURL: String?,
/**This parameter specifies what is to happen if for any reason the validation URL is not reachable. Note that, this is the default action value that determines what M-PESA will do in the scenario that your endpoint is unreachable or is unable to respond on time. Only two values are allowed: Completed or Cancelled. Completed means M-PESA will automatically complete your transaction, whereas Cancelled means M-PESA will automatically cancel the transaction, in the event M-PESA is unable to reach your Validation URL.*/
val validationURL: String,
@SerialName("ResponseType")
val responseType: String? = C2BResponseType.COMPLETED.name,
/**A unique number is tagged to an M-PESA pay bill/till number of the organization.*/
val responseType: String,
@SerialName("ShortCode")
val shortCode: Int,
val shortCode: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import kotlin.native.ObjCName
@ObjCName(swiftName = "C2BRequest")
@Serializable
/**Request C2B M-Pesa payment*/
internal data class C2BRequest(
data class C2BRequest(
@SerialName("Amount")
val amount: Int,
@SerialName("BillRefNumber")
val billReferenceNumber: String,
val billReferenceNumber: String? = null,
@SerialName("CommandID")
val commandID: String,
@SerialName("Msisdn")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ import kotlin.native.ObjCName

@ObjCName(swiftName = "C2BResponse")
@Serializable
/***/
internal data class C2BResponse(
/**This is a global unique identifier for the transaction request returned by the API proxy upon successful request submission.*/
/**
* @param [originatorConversationId] This is a global unique identifier for the transaction request returned by the API proxy upon successful request submission.
* @param [responseCode] It indicates whether Mobile Money accepts the request or not.
* @param [responseDescription] This is the status of the request.
* */
data class C2BResponse(
@SerialName("OriginatorCoversationID")
val originatorCoversationId: String,
/**It indicates whether Mobile Money accepts the request or not.*/
val originatorConversationId: String,
@SerialName("ResponseCode")
val responseCode: String,
/**This is the status of the request.*/
@SerialName("ResponseDescription")
val responseDescription: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,14 @@ internal fun String.getDarajaPhoneNumber(): String {
phoneNumber.matches(Regex("^(?:254)?[17](?:\\d\\d|0[0-8]|(9[0-2]))\\d{6}\$")) -> phoneNumber
phoneNumber.matches(Regex("^0?[17](?:\\d\\d|0[0-8]|(9[0-2]))\\d{6}\$")) ->
phoneNumber.replaceFirst("0", "254")

phoneNumber.matches(Regex("^(?:\\+254)?[17](?:\\d\\d|0[0-8]|(9[0-2]))\\d{6}\$")) ->
phoneNumber.replaceFirst("+", "")

else -> throw DarajaException("Invalid phone number format provided: $this")
}
}

internal fun String.capitalize(): String {
return this.lowercase().replaceFirstChar { it.uppercase() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
@file:OptIn(ExperimentalMaterial3Api::class)

package com.vickbt.daraja.android.ui.screen

import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Send
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vickbt.darajakmp.Daraja
import com.vickbt.darajakmp.utils.DarajaTransactionCode
import com.vickbt.darajakmp.utils.DarajaTransactionType
import com.vickbt.darajakmp.utils.onFailure
import com.vickbt.darajakmp.utils.onSuccess
import java.util.UUID

@Composable
fun C2BInitiateScreen(modifier: Modifier, daraja: Daraja) {
val context = LocalContext.current

var shortCode by remember { mutableStateOf("600997") }
var amount by remember { mutableStateOf(1) }
var phoneNumber by remember { mutableStateOf("") }

var isLoading by remember { mutableStateOf(false) }

Column(
modifier = modifier.wrapContentSize(),
verticalArrangement = Arrangement.spacedBy(
space = 16.dp,
alignment = Alignment.CenterVertically,
),
horizontalAlignment = Alignment.CenterHorizontally
) {

OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = shortCode,
onValueChange = { shortCode = it },
singleLine = true,
maxLines = 1,
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground,
),
label = { Text(text = "Business Short Code") },
colors = TextFieldDefaults.outlinedTextFieldColors(focusedBorderColor = MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
//keyboardActions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next)
)

OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = { amount = it.toInt() },
singleLine = true,
maxLines = 1,
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground,
),
label = { Text(text = "Amount") },
colors = TextFieldDefaults.outlinedTextFieldColors(focusedBorderColor = MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
//keyboardActions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next)
)

OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = phoneNumber,
onValueChange = { phoneNumber = it },
singleLine = true,
maxLines = 1,
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onBackground,
),
label = { Text(text = "Phone Number") },
colors = TextFieldDefaults.outlinedTextFieldColors(focusedBorderColor = MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
//keyboardActions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next)
)

Box(modifier = Modifier) {
FloatingActionButton(
modifier = Modifier.align(Alignment.Center),
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
elevation = FloatingActionButtonDefaults.elevation(),
onClick = {
isLoading = true

daraja.c2b(
amount = 1,
businessShortCode = shortCode,
transactionType = DarajaTransactionType.CustomerBuyGoodsOnline,
phoneNumber = phoneNumber
).onSuccess {
Toast.makeText(context, "Success: $it", Toast.LENGTH_SHORT).show()
isLoading = false
}.onFailure {
Toast.makeText(context, "Error: ${it.errorMessage}", Toast.LENGTH_SHORT)
.show()
isLoading = false
}

},
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
trackColor = MaterialTheme.colorScheme.onPrimary
)
} else {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.AutoMirrored.Rounded.Send,
contentDescription = "Pay"
)
}
}
}
}
}
Loading