PosConfig.kt

/*
 * 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 <http://www.gnu.org/licenses/>
 */

package net.taler.merchantpos.config

import android.os.Build.VERSION.SDK_INT

import net.taler.common.Amount
import net.taler.common.ContractProduct
import net.taler.common.OrderProduct
import net.taler.common.TalerUtils
import net.taler.common.Tax
import net.taler.merchantlib.MerchantConfig

import java.math.BigDecimal
import java.math.RoundingMode
import java.util.UUID

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

sealed class Config
{
    abstract fun isValid(): Boolean

    abstract fun hasPassword(): Boolean

    abstract fun savePassword(): Boolean

    /**
     * Merchant URL + access token
     */
    data class New(
        val merchantUrl: String,
        val accessToken: String,
        val savePassword: Boolean,
    ) : Config()
    {
        override fun isValid() = merchantUrl.isNotBlank()

        override fun hasPassword() = accessToken.isNotBlank()

        override fun savePassword() = savePassword
    }
}

@Serializable
data class PosConfig(
    @SerialName("config")
    val merchantConfig: MerchantConfig? = null,
    val categories: List<Category>,
    val products: List<ConfigProduct>,
)

@Serializable
data class Category(
    val id: Int,
    val name: String,
    @SerialName("name_i18n")
    val nameI18n: Map<String, String>? = null,
)
{
    var selected: Boolean = false
    val localizedName: String
        get() = if (SDK_INT >= 26) TalerUtils.getLocalizedString(nameI18n, name) else name
}

@Serializable
data class ConfigProduct(
    val id: String = UUID.randomUUID().toString(),
    @SerialName("product_id")
    override val productId: String? = null,
    @SerialName("product_name")
    override val productName: String? = null,
    override val description: String,
    @SerialName("description_i18n")
    override val descriptionI18n: Map<String, String>? = null,
    override val price: Amount,
    @SerialName("prices_are_net")
    val pricesAreNet: Boolean = false,
    @SerialName("delivery_location")
    override val location: String? = null,
    override val image: String? = null,
    override val taxes: Set<Tax>? = null,
    val categories: List<Int>,
    val quantity: Int = 0,
    @SerialName("total_stock")
    val totalStock: Int? = null,
    @SerialName("unit_total_stock")
    val unitTotalStock: String? = null,
    @SerialName("total_sold")
    val totalSold: Int? = null,
    @SerialName("unit_total_sold")
    val unitTotalSold: String? = null,
    @SerialName("total_lost")
    val totalLost: Int? = null,
    @SerialName("unit_total_lost")
    val unitTotalLost: String? = null,
    val availableToSell: Boolean = true,
    val remainingStock: Int? = null,
    val currencyMismatch: Boolean = false,
) : OrderProduct()
{
    private val normalizedProductName: String?
        get() = productName?.sanitizeVisibleText()?.takeIf { it.isNotEmpty() }

    private val normalizedDescription: String
        get() = localizedDescription.sanitizeVisibleText()

    val totalPrice: Amount
        get() = (price * quantity).withSpec(price.spec)

    val displayName: String
        get() = normalizedProductName ?: normalizedDescription

    // Description translations only name the product when no separate name is available.
    val displayNameI18n: Map<String, String>?
        get() = if (normalizedProductName == null) descriptionI18n else null

    val displayDescription: String?
        get() =
            normalizedProductName
                ?.takeIf { it != normalizedDescription }
                ?.let { normalizedDescription.takeIf(String::isNotEmpty) }

    val displayPrice: String
        get() = price.toString()

    val stableKey: String
        get() =
            listOf(
                productId?.trim().orEmpty(),
                productName?.trim().orEmpty(),
                description.trim(),
                price.currency,
                price.value.toString(),
                price.fraction.toString(),
                categories.joinToString(","),
            ).joinToString("|")

    val stockLimit: Int?
        get()
        {
            if (totalStock == -1 ||
                unitTotalStock == "-1")
            {
                return null
            }
            val total = unitTotalStock?.toBigDecimalOrNull() ?: totalStock?.toBigDecimal() ?: return null
            val sold = unitTotalSold?.toBigDecimalOrNull() ?: totalSold?.toBigDecimal() ?: BigDecimal.ZERO
            val lost = unitTotalLost?.toBigDecimalOrNull() ?: totalLost?.toBigDecimal() ?: BigDecimal.ZERO
            return (total - sold - lost)
                .coerceIn(BigDecimal.ZERO, Int.MAX_VALUE.toBigDecimal())
                .setScale(0, RoundingMode.DOWN)
                .toInt()
        }

    fun toContractProduct() =
        ContractProduct(
            productId = productId,
            productName = productName ?: description,
            description = description,
            descriptionI18n = descriptionI18n?.takeIf { it.isNotEmpty() },
            price = price,
            pricesAreNet = pricesAreNet,
            location = null,
            image = image?.takeIf { it.isNotBlank() },
            taxes = taxes?.takeIf { it.isNotEmpty() },
            quantity = quantity,
        )
}

private fun String.sanitizeVisibleText(): String =
    buildString {
        var offset = 0
        while (offset < this@sanitizeVisibleText.length)
        {
            val codePoint = Character.codePointAt(this@sanitizeVisibleText, offset)
            when (Character.getType(codePoint))
            {
                Character.FORMAT.toInt(),
                Character.CONTROL.toInt(),
                Character.SURROGATE.toInt(),
                Character.PRIVATE_USE.toInt(),
                Character.UNASSIGNED.toInt()
                    -> Unit

                else -> appendCodePoint(codePoint)
            }
            offset += Character.charCount(codePoint)
        }
    }.trim()