diff --git a/app/src/main/java/com/pax/ecr/app/Action.kt b/app/src/main/java/com/pax/ecr/app/Action.kt index db4f1a2..5131b83 100644 --- a/app/src/main/java/com/pax/ecr/app/Action.kt +++ b/app/src/main/java/com/pax/ecr/app/Action.kt @@ -4,6 +4,7 @@ enum class Action { ADMIN, LOGIN, LOGOUT, + PRINT_RECEIPT, PURCHASE, PURCHASE_W_CASHBACK, REFUND, diff --git a/app/src/main/java/com/pax/ecr/app/MainActivity.kt b/app/src/main/java/com/pax/ecr/app/MainActivity.kt index 25e7df0..948f548 100644 --- a/app/src/main/java/com/pax/ecr/app/MainActivity.kt +++ b/app/src/main/java/com/pax/ecr/app/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -18,10 +19,12 @@ import com.pax.ecr.app.NexoMessages.loginRequest import com.pax.ecr.app.NexoMessages.logout import com.pax.ecr.app.NexoMessages.payment import com.pax.ecr.app.NexoMessages.paymentWithCashback +import com.pax.ecr.app.NexoMessages.receipt import com.pax.ecr.app.NexoMessages.refund import com.pax.ecr.app.NexoMessages.reversal import com.pax.ecr.app.ui.screen.ModeSelectorScreen import com.pax.ecr.app.ui.screen.PaymentModeScreen +import com.pax.ecr.app.ui.screen.PrintReceiptScreen import com.pax.ecr.app.ui.screen.config.ConfigScreen import com.pax.ecr.app.ui.screen.config.ResponseScreen import com.pax.ecr.app.ui.screen.restaurant.RestaurantScreen @@ -44,6 +47,8 @@ var lastTransactionId by mutableStateOf("") var lastTransactionDatetime by mutableStateOf("") var lastResponseTransactionId by mutableStateOf("") var selectedMode by mutableStateOf(null) +var receiptData by mutableStateOf("") +var receiptElements = mutableStateMapOf() enum class Mode { PAYMENT_APPLICATION, @@ -72,6 +77,19 @@ class MainActivity : ComponentActivity() { ResponseScreen(response = responseText) { responseText = "" } + } else if (receiptData.isNotBlank()) { + PrintReceiptScreen( + onPrintReceipt = { + handleAction(Action.PRINT_RECEIPT).also { + receiptData = "" + receiptElements.clear() + } + }, + onContinue = { + receiptData = "" + receiptElements.clear() + }, + ) } else if (configMenuVisible) { ConfigScreen(config, { configMenuVisible = false }) { config = it @@ -195,6 +213,7 @@ class MainActivity : ComponentActivity() { Action.ADMIN -> sendAdminIntent(AdminAction.OPEN_ADMIN_MENU) Action.LOGIN -> sendMessageIntent(loginRequest()) Action.LOGOUT -> sendMessageIntent(logout()) + Action.PRINT_RECEIPT -> sendMessageIntent(receipt(receiptData)) Action.PURCHASE -> sendMessageIntent(payment(amount = BigDecimal.TEN)) Action.PURCHASE_W_CASHBACK -> sendMessageIntent(paymentWithCashback()) Action.REFUND -> sendMessageIntent(refund()) diff --git a/app/src/main/java/com/pax/ecr/app/NexoMessageIntentReceiver.kt b/app/src/main/java/com/pax/ecr/app/NexoMessageIntentReceiver.kt index 5f14c3e..98e1c9e 100644 --- a/app/src/main/java/com/pax/ecr/app/NexoMessageIntentReceiver.kt +++ b/app/src/main/java/com/pax/ecr/app/NexoMessageIntentReceiver.kt @@ -4,14 +4,24 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.widget.Toast +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.json.Json import org.w3c.dom.Document +import org.w3c.dom.NodeList import org.xml.sax.InputSource import org.xml.sax.SAXParseException import java.io.StringReader import java.nio.charset.Charset import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathConstants +import javax.xml.xpath.XPathFactory +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi class NexoMessageIntentReceiver : BroadcastReceiver() { + private val json = Json { ignoreUnknownKeys = true } + override fun onReceive( context: Context, intent: Intent, @@ -21,12 +31,14 @@ class NexoMessageIntentReceiver : BroadcastReceiver() { lastResponseTransactionId = nexoMessage.extractPOITransactionID() lastTransactionDatetime = nexoMessage.extractPOITimeStamp() responseText = nexoMessage.orEmpty() + receiptData = nexoMessage.extractCustomerReceipt()?.toReceiptData(receiptElements).orEmpty() if (nexoMessage.isLoginResponseFailure()) { Toast.makeText( context, - "Login failed", + "Login failed. Perhaps the POI is new? Try again in a few seconds.", Toast.LENGTH_LONG, ).show() + selectedMode = null } } @@ -36,6 +48,32 @@ class NexoMessageIntentReceiver : BroadcastReceiver() { }.also { context.startActivity(it) } } + @OptIn(ExperimentalEncodingApi::class, ExperimentalSerializationApi::class) + private fun String?.extractCustomerReceipt() = + try { + val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + val inputSource = InputSource(StringReader(this)) + val document: Document = documentBuilder.parse(inputSource) + + val xPath = XPathFactory.newInstance().newXPath() + val expression = "//PaymentReceipt[@DocumentQualifier='CustomerReceipt']/OutputContent/OutputText" + val nodeList = xPath.evaluate(expression, document, XPathConstants.NODESET) as NodeList + + if (nodeList.length > 0) { + val base64EncodedText = nodeList.item(0).textContent.trim() + val decodedBytes = Base64.decode(base64EncodedText) + json.decodeFromString(String(decodedBytes, Charsets.UTF_8)) + } else { + null + } + } catch (e: SAXParseException) { + null + } catch (e: NullPointerException) { + null + } catch (e: MissingFieldException) { + null + } + private fun String?.extractPOITransactionID() = extractCommon { val poiTransactionIDNode = it.getElementsByTagName("POITransactionID")?.item(0) diff --git a/app/src/main/java/com/pax/ecr/app/NexoMessages.kt b/app/src/main/java/com/pax/ecr/app/NexoMessages.kt index e733ab6..05f3188 100644 --- a/app/src/main/java/com/pax/ecr/app/NexoMessages.kt +++ b/app/src/main/java/com/pax/ecr/app/NexoMessages.kt @@ -1,5 +1,6 @@ package com.pax.ecr.app +import kotlinx.serialization.Serializable import java.math.BigDecimal import java.nio.charset.Charset import java.text.DecimalFormat @@ -84,7 +85,7 @@ object NexoMessages { fun reversal() = """ - + @@ -93,6 +94,20 @@ object NexoMessages { """.trimIndent().toByteArray(Charset.defaultCharset()) + fun receipt(receiptData: String) = + """ + + + + + + $receiptData + + + + + """.trimIndent().toByteArray(Charset.defaultCharset()) + private fun randomServiceId() = Random.nextInt(0, Int.MAX_VALUE) private fun randomTransactionId() = @@ -102,3 +117,140 @@ object NexoMessages { private fun now() = ZonedDateTime.now(ZoneId.of("Europe/Oslo")).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) } + +@Serializable +data class CardholderReceipt( + val Cardholder: Cardholder, +) { + fun toReceiptData(items: Map = emptyMap()): String { + return """ + Customer Receipt + Check out the integration guide for examples + ${Cardholder.Mandatory.Outcome.AuthorisationResponder} ${Cardholder.Mandatory.Outcome.DebitStatus} ${Cardholder.Mandatory.Outcome.ApprovalCode} ${Cardholder.Mandatory.Outcome.AuthorisationResponseCode} + ${Cardholder.Mandatory.TimeStamp.TimeOfPayment} ${Cardholder.Mandatory.TimeStamp.DateOfPayment} + + + ${if (items.isNotEmpty()) { + items.keys.mapIndexed { + index, + key, + -> + "${items[key]} $key" + }.joinToString("\n") + } else { + "" + }} + + + + + ${Cardholder.Mandatory.Payment.PaymentAmount} ${Cardholder.Mandatory.Payment.Currency} + + + + + + + + """.trimIndent() + } +} + +@Serializable +data class Cardholder( + val Mandatory: Mandatory, + val Optional: Optional? = null, +) + +@Serializable +data class Mandatory( + val Acquirer: Acquirer, + val CardAcceptor: CardAcceptor, + val CardDetails: CardDetails, + val Outcome: Outcome, + val Payment: Payment, + val TimeStamp: TimeStamp, +) + +@Serializable +data class Acquirer( + val CardAcceptorNumber: String, + val TerminalID: String, +) + +@Serializable +data class CardAcceptor( + val Address1: String, + val BankAgentName: String, + val Name: String, + val OperatorNumber: String, + val OrganisationNumber: String, + val PostZipCode: String, + val TownCity: String, +) + +@Serializable +data class CardDetails( + val ApplicationIdentifier: String, + val CardSchemeName: CardSchemeName, + val PrimaryAccountNumber: String, + val TerminalVerificationResult: String, + val TransactionStatusInformation: String, +) + +@Serializable +data class CardSchemeName( + val ApplicationLabel: String, +) + +@Serializable +data class Outcome( + val ApprovalCode: String? = null, + val AuthorisationResponder: String, + val AuthorisationResponseCode: String? = null, + val DebitStatus: String, +) + +@Serializable +data class Payment( + val AuthorisationChannel: String, + val CardholderVerificationMethod: String, + val Currency: String, + val FinancialInstitution: String, + val PaymentAmount: String, + val ReceiptNumber: String, + val SignatureBlock: Boolean, + val TotalAmount: String, + val TransactionSource: String, + val TransactionType: String, +) + +@Serializable +data class TimeStamp( + val DateOfPayment: String, + val TimeOfPayment: String, +) + +@Serializable +data class Optional( + val CardAcceptor: OptionalCardAcceptor? = null, + val CardDetails: OptionalCardDetails? = null, + val Payment: OptionalPayment? = null, + val ReceiptString: List? = null, +) + +@Serializable +data class OptionalCardAcceptor( + val CountryName: String, +) + +@Serializable +data class OptionalCardDetails( + val CardIssuerNumber: String, + val CardSchemeName: CardSchemeName, +) + +@Serializable +data class OptionalPayment( + val Reference: String, +) diff --git a/app/src/main/java/com/pax/ecr/app/ui/screen/PrintReceiptScreen.kt b/app/src/main/java/com/pax/ecr/app/ui/screen/PrintReceiptScreen.kt new file mode 100644 index 0000000..273b937 --- /dev/null +++ b/app/src/main/java/com/pax/ecr/app/ui/screen/PrintReceiptScreen.kt @@ -0,0 +1,64 @@ +package com.pax.ecr.app.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun PrintReceiptScreen( + onPrintReceipt: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Transaction Complete", + style = MaterialTheme.typography.titleLarge, + fontSize = 24.sp, + modifier = Modifier.padding(bottom = 32.dp), + ) + + Button( + onClick = { onPrintReceipt() }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Text(text = "Print receipt") + } + + Button( + onClick = { onContinue() }, + colors = + ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White, + ), + modifier = + Modifier + .fillMaxWidth(), + ) { + Text(text = "Continue") + } + } +} diff --git a/app/src/main/java/com/pax/ecr/app/ui/screen/restaurant/RestaurantScreen.kt b/app/src/main/java/com/pax/ecr/app/ui/screen/restaurant/RestaurantScreen.kt index 09a5127..f6083ef 100644 --- a/app/src/main/java/com/pax/ecr/app/ui/screen/restaurant/RestaurantScreen.kt +++ b/app/src/main/java/com/pax/ecr/app/ui/screen/restaurant/RestaurantScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pax.ecr.app.R +import com.pax.ecr.app.receiptElements import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -145,6 +146,7 @@ private fun ItemList() { it.amountSelected.intValue++ selected++ price += it.price + receiptElements[it.title] = receiptElements.getOrDefault(it.title, 0) + 1 }, ) { Column(modifier = Modifier.weight(2f)) { diff --git a/app/src/main/java/com/pax/ecr/app/ui/screen/retail/RetailerScreen.kt b/app/src/main/java/com/pax/ecr/app/ui/screen/retail/RetailerScreen.kt index 82c6578..a603365 100644 --- a/app/src/main/java/com/pax/ecr/app/ui/screen/retail/RetailerScreen.kt +++ b/app/src/main/java/com/pax/ecr/app/ui/screen/retail/RetailerScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pax.ecr.app.R +import com.pax.ecr.app.receiptElements import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -97,6 +98,7 @@ private fun ItemList() { it.amountSelected.intValue++ selected++ price += it.price + receiptElements[it.title] = receiptElements.getOrDefault(it.title, 0) + 1 }, ) { Image(