Skip to content

Commit

Permalink
Added basic print-receipt functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
torland-klev committed Oct 10, 2024
1 parent aea80b7 commit 3af4427
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 2 deletions.
1 change: 1 addition & 0 deletions app/src/main/java/com/pax/ecr/app/Action.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enum class Action {
ADMIN,
LOGIN,
LOGOUT,
PRINT_RECEIPT,
PURCHASE,
PURCHASE_W_CASHBACK,
REFUND,
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/pax/ecr/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ 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
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
Expand All @@ -44,6 +47,8 @@ var lastTransactionId by mutableStateOf("")
var lastTransactionDatetime by mutableStateOf("")
var lastResponseTransactionId by mutableStateOf("")
var selectedMode by mutableStateOf<Mode?>(null)
var receiptData by mutableStateOf("")
var receiptElements = mutableStateMapOf<String, Int>()

enum class Mode {
PAYMENT_APPLICATION,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
40 changes: 39 additions & 1 deletion app/src/main/java/com/pax/ecr/app/NexoMessageIntentReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}

Expand All @@ -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<CardholderReceipt>(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)
Expand Down
154 changes: 153 additions & 1 deletion app/src/main/java/com/pax/ecr/app/NexoMessages.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -84,7 +85,7 @@ object NexoMessages {
fun reversal() =
"""
<SaleToPOIRequest>
<MessageHeader MessageCategory="Reversal" MessageClass="Service" MessageType="Request" POIID="${config.poiId}" SaleID="${config.saleId}" ServiceID="${randomServiceId()}"/>
<MessageHeader MessageCategory="Reversal" MessageClass="Service" MessageType="Request" POIID="${config.poiId}" ProtocolVersion="3.1" SaleID="${config.saleId}" ServiceID="${randomServiceId()}"/>
<ReversalRequest ReversalReason="MerchantCancel">
<OriginalPOITransaction POIID="${config.poiId}" SaleID="${config.saleId}">
<POITransactionID TimeStamp="$lastTransactionDatetime" TransactionID="$lastResponseTransactionId" />
Expand All @@ -93,6 +94,20 @@ object NexoMessages {
</SaleToPOIRequest>
""".trimIndent().toByteArray(Charset.defaultCharset())

fun receipt(receiptData: String) =
"""
<SaleToPOIRequest>
<MessageHeader MessageClass="Device" MessageCategory="Print" MessageType="Request" POIID="${config.poiId}" ProtocolVersion="3.1" SaleID="${config.saleId}" ServiceID="${randomServiceId()}" DeviceID="DEMO"/>
<PrintRequest>
<PrintOutput DocumentQualifier="SaleReceipt" ResponseMode="PrintEnd">
<OutputContent OutputFormat="Text">
$receiptData
</OutputContent>
</PrintOutput>
</PrintRequest>
</SaleToPOIRequest>
""".trimIndent().toByteArray(Charset.defaultCharset())

private fun randomServiceId() = Random.nextInt(0, Int.MAX_VALUE)

private fun randomTransactionId() =
Expand All @@ -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<String, Int> = emptyMap()): String {
return """
<OutputText StartRow="1">Customer Receipt</OutputText>
<OutputText StartRow="2">Check out the integration guide for examples</OutputText>
<OutputText StartRow="4">${Cardholder.Mandatory.Outcome.AuthorisationResponder} ${Cardholder.Mandatory.Outcome.DebitStatus} ${Cardholder.Mandatory.Outcome.ApprovalCode} ${Cardholder.Mandatory.Outcome.AuthorisationResponseCode}</OutputText>
<OutputText StartRow="5">${Cardholder.Mandatory.TimeStamp.TimeOfPayment} ${Cardholder.Mandatory.TimeStamp.DateOfPayment}
</OutputText>
${if (items.isNotEmpty()) {
items.keys.mapIndexed {
index,
key,
->
"<OutputText StartRow=\"${index + 6}\">${items[key]} $key</OutputText>"
}.joinToString("\n")
} else {
""
}}
<OutputText StartRow="${items.size + 7}">
</OutputText>
<OutputText StartRow="${items.size + 8}">${Cardholder.Mandatory.Payment.PaymentAmount} ${Cardholder.Mandatory.Payment.Currency}
</OutputText>
""".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<String>? = 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,
)
64 changes: 64 additions & 0 deletions app/src/main/java/com/pax/ecr/app/ui/screen/PrintReceiptScreen.kt
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
Loading

0 comments on commit 3af4427

Please sign in to comment.