From 7eebd0754d16eccadeb3d1cb53c1cebffda65d07 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 16 Mar 2020 15:53:58 -0300 Subject: Add refund button to history items and allow to refund orders (still hidden in UI because API incomplete/broken) --- .../java/net/taler/merchantpos/MainViewModel.kt | 2 + app/src/main/java/net/taler/merchantpos/Utils.kt | 4 + .../taler/merchantpos/history/HistoryManager.kt | 6 +- .../merchantpos/history/MerchantHistoryFragment.kt | 26 ++++- .../taler/merchantpos/history/RefundFragment.kt | 99 +++++++++++++++++ .../net/taler/merchantpos/history/RefundManager.kt | 111 +++++++++++++++++++ .../taler/merchantpos/history/RefundUriFragment.kt | 65 +++++++++++ app/src/main/res/drawable/ic_cash_refund.xml | 9 ++ app/src/main/res/layout/fragment_refund.xml | 122 +++++++++++++++++++++ app/src/main/res/layout/fragment_refund_uri.xml | 93 ++++++++++++++++ app/src/main/res/layout/list_item_history.xml | 21 +++- app/src/main/res/navigation/nav_graph.xml | 25 ++++- app/src/main/res/values/strings.xml | 12 ++ 13 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/net/taler/merchantpos/history/RefundFragment.kt create mode 100644 app/src/main/java/net/taler/merchantpos/history/RefundManager.kt create mode 100644 app/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt create mode 100644 app/src/main/res/drawable/ic_cash_refund.xml create mode 100644 app/src/main/res/layout/fragment_refund.xml create mode 100644 app/src/main/res/layout/fragment_refund_uri.xml diff --git a/app/src/main/java/net/taler/merchantpos/MainViewModel.kt b/app/src/main/java/net/taler/merchantpos/MainViewModel.kt index 560ca59..3fe472d 100644 --- a/app/src/main/java/net/taler/merchantpos/MainViewModel.kt +++ b/app/src/main/java/net/taler/merchantpos/MainViewModel.kt @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import net.taler.merchantpos.config.ConfigManager import net.taler.merchantpos.history.HistoryManager +import net.taler.merchantpos.history.RefundManager import net.taler.merchantpos.order.OrderManager import net.taler.merchantpos.payment.PaymentManager @@ -41,6 +42,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { } val paymentManager = PaymentManager(configManager, queue, mapper) val historyManager = HistoryManager(configManager, queue, mapper) + val refundManager = RefundManager(configManager, queue) override fun onCleared() { queue.cancelAll { !it.isCanceled } diff --git a/app/src/main/java/net/taler/merchantpos/Utils.kt b/app/src/main/java/net/taler/merchantpos/Utils.kt index 507d142..a0c30d6 100644 --- a/app/src/main/java/net/taler/merchantpos/Utils.kt +++ b/app/src/main/java/net/taler/merchantpos/Utils.kt @@ -30,11 +30,13 @@ import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE import androidx.annotation.StringRes +import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.Observer import androidx.navigation.NavController import androidx.navigation.NavDirections +import androidx.navigation.fragment.findNavController import com.google.android.material.snackbar.BaseTransientBottomBar.ANIMATION_MODE_FADE import com.google.android.material.snackbar.BaseTransientBottomBar.Duration import com.google.android.material.snackbar.Snackbar.make @@ -107,6 +109,8 @@ fun topSnackbar(view: View, @StringRes resId: Int, @Duration duration: Int) { fun NavDirections.navigate(nav: NavController) = nav.navigate(this) +fun Fragment.navigate(directions: NavDirections) = findNavController().navigate(directions) + fun Long.toRelativeTime(context: Context): CharSequence { val now = System.currentTimeMillis() return if (now - this > DAY_IN_MILLIS * 2) { diff --git a/app/src/main/java/net/taler/merchantpos/history/HistoryManager.kt b/app/src/main/java/net/taler/merchantpos/history/HistoryManager.kt index 1459876..594e7cc 100644 --- a/app/src/main/java/net/taler/merchantpos/history/HistoryManager.kt +++ b/app/src/main/java/net/taler/merchantpos/history/HistoryManager.kt @@ -16,10 +16,12 @@ package net.taler.merchantpos.history +import android.util.Log import androidx.annotation.UiThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.android.volley.Request.Method.GET +import com.android.volley.Request.Method.POST import com.android.volley.RequestQueue import com.android.volley.Response.ErrorListener import com.android.volley.Response.Listener @@ -79,7 +81,7 @@ class HistoryManager( val params = mapOf("instance" to merchantConfig.instance) val req = MerchantRequest(GET, merchantConfig, "history", params, null, Listener { onHistoryResponse(it) }, - ErrorListener { onNetworkError() }) + ErrorListener { onHistoryError() }) queue.add(req) } @@ -96,7 +98,7 @@ class HistoryManager( } @UiThread - private fun onNetworkError() { + private fun onHistoryError() { mIsLoading.value = false mItems.value = HistoryResult.Error } diff --git a/app/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt b/app/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt index 5299f28..0c53f71 100644 --- a/app/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt +++ b/app/src/main/java/net/taler/merchantpos/history/MerchantHistoryFragment.kt @@ -22,11 +22,11 @@ import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageButton import android.widget.TextView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Observer -import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager @@ -40,14 +40,19 @@ import net.taler.merchantpos.R import net.taler.merchantpos.exhaustive import net.taler.merchantpos.history.HistoryItemAdapter.HistoryItemViewHolder import net.taler.merchantpos.history.MerchantHistoryFragmentDirections.Companion.actionGlobalMerchantSettings +import net.taler.merchantpos.history.MerchantHistoryFragmentDirections.Companion.actionNavHistoryToRefundFragment import net.taler.merchantpos.navigate import net.taler.merchantpos.toRelativeTime import java.util.* +private interface RefundClickListener { + fun onRefundClicked(item: HistoryItem) +} + /** * Fragment to display the merchant's payment history, received from the backend. */ -class MerchantHistoryFragment : Fragment() { +class MerchantHistoryFragment : Fragment(), RefundClickListener { companion object { const val TAG = "taler-merchant" @@ -55,8 +60,9 @@ class MerchantHistoryFragment : Fragment() { private val model: MainViewModel by activityViewModels() private val historyManager by lazy { model.historyManager } + private val refundManager by lazy { model.refundManager } - private val historyListAdapter = HistoryItemAdapter() + private val historyListAdapter = HistoryItemAdapter(this) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -91,7 +97,7 @@ class MerchantHistoryFragment : Fragment() { override fun onStart() { super.onStart() if (model.configManager.merchantConfig?.instance == null) { - actionGlobalMerchantSettings().navigate(findNavController()) + navigate(actionGlobalMerchantSettings()) } else { historyManager.fetchHistory() } @@ -101,9 +107,15 @@ class MerchantHistoryFragment : Fragment() { Snackbar.make(view!!, R.string.error_network, LENGTH_SHORT).show() } + override fun onRefundClicked(item: HistoryItem) { + refundManager.startRefund(item) + navigate(actionNavHistoryToRefundFragment()) + } + } -internal class HistoryItemAdapter : Adapter() { +private class HistoryItemAdapter(private val listener: RefundClickListener) : + Adapter() { private val items = ArrayList() @@ -125,12 +137,13 @@ internal class HistoryItemAdapter : Adapter() { this.notifyDataSetChanged() } - internal class HistoryItemViewHolder(private val v: View) : ViewHolder(v) { + private inner class HistoryItemViewHolder(private val v: View) : ViewHolder(v) { private val orderSummaryView: TextView = v.findViewById(R.id.orderSummaryView) private val orderAmountView: TextView = v.findViewById(R.id.orderAmountView) private val orderTimeView: TextView = v.findViewById(R.id.orderTimeView) private val orderIdView: TextView = v.findViewById(R.id.orderIdView) + private val refundButton: ImageButton = v.findViewById(R.id.refundButton) fun bind(item: HistoryItem) { orderSummaryView.text = item.summary @@ -139,6 +152,7 @@ internal class HistoryItemAdapter : Adapter() { orderAmountView.text = "${amount.amount} ${amount.currency}" orderIdView.text = v.context.getString(R.string.history_ref_no, item.orderId) orderTimeView.text = item.time.toRelativeTime(v.context) + refundButton.setOnClickListener { listener.onRefundClicked(item) } } } diff --git a/app/src/main/java/net/taler/merchantpos/history/RefundFragment.kt b/app/src/main/java/net/taler/merchantpos/history/RefundFragment.kt new file mode 100644 index 0000000..1797cea --- /dev/null +++ b/app/src/main/java/net/taler/merchantpos/history/RefundFragment.kt @@ -0,0 +1,99 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.merchantpos.history + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Observer +import androidx.navigation.fragment.findNavController +import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG +import com.google.android.material.snackbar.Snackbar +import kotlinx.android.synthetic.main.fragment_refund.* +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.R +import net.taler.merchantpos.fadeIn +import net.taler.merchantpos.fadeOut +import net.taler.merchantpos.history.RefundFragmentDirections.Companion.actionRefundFragmentToRefundUriFragment +import net.taler.merchantpos.history.RefundResult.Error +import net.taler.merchantpos.history.RefundResult.PastDeadline +import net.taler.merchantpos.history.RefundResult.Success +import net.taler.merchantpos.navigate + +class RefundFragment : Fragment() { + + private val model: MainViewModel by activityViewModels() + private val refundManager by lazy { model.refundManager } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_refund, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val item = refundManager.toBeRefunded ?: throw IllegalStateException() + amountInputView.setText(item.amount.amount) + currencyView.text = item.amount.currency + abortButton.setOnClickListener { findNavController().navigateUp() } + refundButton.setOnClickListener { onRefundButtonClicked(item) } + + refundManager.refundResult.observe(viewLifecycleOwner, Observer { result -> + onRefundResultChanged(result) + }) + } + + private fun onRefundButtonClicked(item: HistoryItem) { + val inputAmount = amountInputView.text.toString().toDouble() + if (inputAmount > item.amount.amount.toDouble()) { + amountView.error = getString(R.string.refund_error_max_amount, item.amount.amount) + return + } + if (inputAmount <= 0.0) { + amountView.error = getString(R.string.refund_error_zero) + return + } + amountView.error = null + refundButton.fadeOut() + progressBar.fadeIn() + refundManager.refund(item, inputAmount, reasonInputView.text.toString()) + } + + private fun onRefundResultChanged(result: RefundResult?): Any = when (result) { + Error -> onError(R.string.refund_error_backend) + PastDeadline -> onError(R.string.refund_error_deadline) + is Success -> { + progressBar.fadeOut() + refundButton.fadeIn() + navigate(actionRefundFragmentToRefundUriFragment()) + } + null -> { // no-op + } + } + + private fun onError(@StringRes res: Int) { + Snackbar.make(view!!, res, LENGTH_LONG).show() + progressBar.fadeOut() + refundButton.fadeIn() + } + +} diff --git a/app/src/main/java/net/taler/merchantpos/history/RefundManager.kt b/app/src/main/java/net/taler/merchantpos/history/RefundManager.kt new file mode 100644 index 0000000..270b3b8 --- /dev/null +++ b/app/src/main/java/net/taler/merchantpos/history/RefundManager.kt @@ -0,0 +1,111 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.merchantpos.history + +import android.util.Log +import androidx.annotation.UiThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.android.volley.Request.Method.POST +import com.android.volley.RequestQueue +import com.android.volley.Response.ErrorListener +import com.android.volley.Response.Listener +import net.taler.merchantpos.config.ConfigManager +import net.taler.merchantpos.config.MerchantRequest +import org.json.JSONObject + +sealed class RefundResult { + object Error : RefundResult() + object PastDeadline : RefundResult() + class Success( + val refundUri: String, + val item: HistoryItem, + val amount: Double, + val reason: String + ) : RefundResult() +} + +class RefundManager( + private val configManager: ConfigManager, + private val queue: RequestQueue +) { + + var toBeRefunded: HistoryItem? = null + private set + + private val mRefundResult = MutableLiveData() + internal val refundResult: LiveData = mRefundResult + + @UiThread + internal fun startRefund(item: HistoryItem) { + toBeRefunded = item + mRefundResult.value = null + } + + @UiThread + internal fun refund(item: HistoryItem, amount: Double, reason: String) { + val merchantConfig = configManager.merchantConfig!! + val refundRequest = mapOf( + "order_id" to item.orderId, + "refund" to "${item.amount.currency}:$amount", + "reason" to reason + ) + val body = JSONObject(refundRequest) + val req = MerchantRequest(POST, merchantConfig, "refund", null, body, + Listener { onRefundResponse(it, item, amount, reason) }, + ErrorListener { onRefundError() } + ) + queue.add(req) + } + + @UiThread + private fun onRefundResponse( + json: JSONObject, + item: HistoryItem, + amount: Double, + reason: String + ) { + if (!json.has("contract_terms")) { + Log.e("TEST", "json: $json") + onRefundError() + return + } + + val contractTerms = json.getJSONObject("contract_terms") + val refundDeadline = if (contractTerms.has("refund_deadline")) { + contractTerms.getJSONObject("refund_deadline").getLong("t_ms") + } else null + val autoRefund = contractTerms.has("auto_refund") + val refundUri = json.getString("taler_refund_uri") + + Log.e("TEST", "refundDeadline: $refundDeadline") + if (refundDeadline != null) Log.e( + "TEST", + "refundDeadline passed: ${System.currentTimeMillis() > refundDeadline}" + ) + Log.e("TEST", "autoRefund: $autoRefund") + Log.e("TEST", "refundUri: $refundUri") + + mRefundResult.value = RefundResult.Success(refundUri, item, amount, reason) + } + + @UiThread + private fun onRefundError() { + mRefundResult.value = RefundResult.Error + } + +} diff --git a/app/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt b/app/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt new file mode 100644 index 0000000..f2bd569 --- /dev/null +++ b/app/src/main/java/net/taler/merchantpos/history/RefundUriFragment.kt @@ -0,0 +1,65 @@ +/* + * This file is part of GNU Taler + * (C) 2020 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.merchantpos.history + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import kotlinx.android.synthetic.main.fragment_refund_uri.* +import net.taler.merchantpos.MainViewModel +import net.taler.merchantpos.NfcManager.Companion.hasNfc +import net.taler.merchantpos.QrCodeManager.makeQrCode +import net.taler.merchantpos.R + +class RefundUriFragment : Fragment() { + + private val model: MainViewModel by activityViewModels() + private val refundManager by lazy { model.refundManager } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_refund_uri, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val result = refundManager.refundResult.value + if (result !is RefundResult.Success) throw IllegalStateException() + + refundQrcodeView.setImageBitmap(makeQrCode(result.refundUri)) + + val introRes = + if (hasNfc(requireContext())) R.string.refund_intro_nfc else R.string.refund_intro + refundIntroView.setText(introRes) + + @SuppressLint("SetTextI18n") + refundAmountView.text = "${result.amount} ${result.item.amount.currency}" + + refundRefView.text = + getString(R.string.refund_order_ref, result.item.orderId, result.reason) + + cancelRefundButton.setOnClickListener { findNavController().navigateUp() } + } + +} diff --git a/app/src/main/res/drawable/ic_cash_refund.xml b/app/src/main/res/drawable/ic_cash_refund.xml new file mode 100644 index 0000000..7359ca3 --- /dev/null +++ b/app/src/main/res/drawable/ic_cash_refund.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_refund.xml b/app/src/main/res/layout/fragment_refund.xml new file mode 100644 index 0000000..5a78cdd --- /dev/null +++ b/app/src/main/res/layout/fragment_refund.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + +