SumUpRestApi.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 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.paymentoptions.sumup

import net.taler.common.Amount
import net.taler.common.getDefaultHttpClient

import java.math.BigDecimal

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
 * The parts of SumUp's REST API a refund needs. The reader SDK cannot refund at all,
 * so this is the only way to give card money back from the app.
 */
internal interface SumUpRestApi
{
    /** Resolves the code a checkout returned into the transaction id a refund needs. */
    suspend fun transactionId(
        merchantCode: String,
        transactionCode: String,
        token: String,
    ): RestResult<String>

    /** Refunds [amount], or the full payment when [amount] is null. */
    suspend fun refund(
        merchantCode: String,
        transactionId: String,
        amount: Amount?,
        token: String,
    ): RestResult<Unit>
}

internal sealed interface RestResult<out T>
{
    data class Success<T>(
        val value: T,
    ) : RestResult<T>

    data class Failure(
        val message: String,
    ) : RestResult<Nothing>
}

@Serializable
private data class TransactionResponse(
    val id: String,
    @SerialName("transaction_code") val transactionCode: String? = null,
    val status: String? = null,
)

@Serializable
private data class RefundBody(
    val amount: Double,
)

internal class SumUpHttpRestApi(
    private val client: HttpClient = getDefaultHttpClient(followRedirect = true),
    private val baseUrl: String = "https://api.sumup.com",
) : SumUpRestApi
{
    override suspend fun transactionId(
        merchantCode: String,
        transactionCode: String,
        token: String,
    ): RestResult<String> =
        call {
            val response: TransactionResponse =
                client
                    .get("$baseUrl/v2.1/merchants/$merchantCode/transactions") {
                        header(HttpHeaders.Authorization, "Bearer $token")
                        parameter("transaction_code", transactionCode)
                    }.body()
            response.id
        }

    override suspend fun refund(
        merchantCode: String,
        transactionId: String,
        amount: Amount?,
        token: String,
    ): RestResult<Unit> =
        call {
            client.post("$baseUrl/v1.0/merchants/$merchantCode/payments/$transactionId/refunds") {
                header(HttpHeaders.Authorization, "Bearer $token")
                contentType(ContentType.Application.Json)
                // No amount at all means a full refund, which is not what a part refund wants.
                amount?.let { setBody(RefundBody(it.toBigDecimal().toDouble())) }
            }
            Unit
        }

    private suspend fun <T> call(block: suspend () -> T): RestResult<T> =
        try
        {
            RestResult.Success(block())
        }
        catch (e: CancellationException)
        {
            throw e
        }
        catch (e: ResponseException)
        {
            val body =
                try
                {
                    e.response.bodyAsText()
                }
                catch (cancelled: CancellationException)
                {
                    throw cancelled
                }
                catch (_: Exception)
                {
                    ""
                }
            RestResult.Failure("${e.response.status.value}: ${body.take(200)}")
        }
        catch (e: Exception)
        {
            RestResult.Failure(e.message ?: e.javaClass.simpleName)
        }
}

/** SumUp takes a plain decimal amount, so the fractional part has to be exact. */
internal fun Amount.toBigDecimal(): BigDecimal =
    BigDecimal.valueOf(value).add(BigDecimal.valueOf(fraction.toLong(), 8)).stripTrailingZeros()