diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt index 66715694..d000dad7 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/PretixApi.kt @@ -99,7 +99,22 @@ open class PretixApi(url: String, key: String, orgaSlug: String, version: Int, h } @Throws(ApiException::class, JSONException::class) - open fun redeem(lists: List, secret: String, datetime: String?, force: Boolean, nonce: String?, answers: List?, ignore_unpaid: Boolean, pdf_data: Boolean, type: String?, source_type: String?, callTimeout: Long? = null, questions_supported: Boolean = true): ApiResponse { + open fun redeem( + lists: List, + secret: String, + datetime: String?, + force: Boolean, + nonce: String?, + answers: List?, + ignore_unpaid: Boolean, + pdf_data: Boolean, + type: String?, + source_type: String?, + callTimeout: Long? = null, + questions_supported: Boolean = true, + exchange_medium_type: String? = null, + exchange_medium_identifier: String? = null, + ): ApiResponse { val body = JSONObject() if (datetime != null) { body.put("datetime", datetime) @@ -135,6 +150,8 @@ open class PretixApi(url: String, key: String, orgaSlug: String, version: Int, h jlists.put(l) } body.put("lists", jlists) + if (exchange_medium_type != null) body.put("exchange_medium_type", exchange_medium_type) + if (exchange_medium_identifier != null) body.put("exchange_medium_identifier", exchange_medium_identifier) var pd = "?expand=answers.question" if (pdf_data) { pd += "&pdf_data=true" diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt index 3021f0bb..800512b6 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/api/ProxyApi.kt @@ -25,7 +25,9 @@ data class MultiCheckInput( // TODO: Check unused values val allowQuestions: Boolean, - val nonce: String? + val nonce: String?, + val exchange_medium_type: String?, + val exchange_medium_identifier: String?, ) data class CheckInput( diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt index b37f653c..41290e63 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -8,9 +8,12 @@ import eu.pretix.libpretixsync.crypto.isValidSignature import eu.pretix.libpretixsync.crypto.readPubkeyFromPem import eu.pretix.libpretixsync.crypto.sig1.TicketProtos import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy import eu.pretix.libpretixsync.db.NonceGenerator import eu.pretix.libpretixsync.db.QuestionLike +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.CheckIn +import eu.pretix.libpretixsync.models.CheckInList import eu.pretix.libpretixsync.models.Event import eu.pretix.libpretixsync.models.Order as OrderModel import eu.pretix.libpretixsync.models.OrderPosition as OrderPositionModel @@ -36,6 +39,7 @@ import java.nio.charset.Charset import java.time.Instant import java.time.OffsetDateTime import java.util.* +import kotlin.collections.filter class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDatabase) : TicketCheckProvider { private var sentry: SentryInterface = DummySentryImplementation() @@ -87,6 +91,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ) } + @Suppress("UNCHECKED_CAST") private fun initJsonLogic(event: Event, subeventId: Long, tz: DateTimeZone): JsonLogic { val jsonLogic = JsonLogic() jsonLogic.addOperation("objectList") { l, _ -> l } @@ -279,7 +284,14 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa return RSAResult(givenAnswers, requiredAnswers, shownAnswers, askQuestions) } - private fun checkOfflineWithoutData(eventsAndCheckinLists: Map, ticketid: String, type: TicketCheckProvider.CheckInType, answers: List?, nonce: String?, allowQuestions: Boolean): TicketCheckProvider.CheckResult { + private fun checkOfflineWithoutData( + eventsAndCheckinLists: Map, + ticketid: String, + type: TicketCheckProvider.CheckInType, + answers: List?, + nonce: String?, + allowQuestions: Boolean + ): TicketCheckProvider.CheckResult { val dt = now() val events = db.eventQueries.selectBySlugList(eventsAndCheckinLists.keys.toList()) .executeAsList() @@ -544,23 +556,80 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa type: TicketCheckProvider.CheckInType, nonce: String?, allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val ticketid_cleaned = cleanInput(ticketid, source_type) sentry.addBreadcrumb("provider.check", "offline check started") + if (exchange_medium_type != null || exchange_medium_identifier != null) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.ERROR, + "Media exchange is not supported in offline mode", + offline = true + ) + } + val tickets = db.orderPositionQueries.selectBySecretAndEventSlugs( secret = ticketid_cleaned, event_slugs = eventsAndCheckinLists.keys.toList(), ).executeAsList().map { it.toModel() } - if (tickets.size == 0) { + if (tickets.size == 1) { + return checkOfflineWithData(eventsAndCheckinLists, ticketid_cleaned, tickets, answers, ignore_unpaid, type, nonce = nonce, allowQuestions = allowQuestions, mediumUsed = false) + } else if (tickets.size > 1) { + val eventSlug = db.orderQueries.selectById(tickets[0].orderId).executeAsOne().event_slug!! + val itemServerId = db.itemQueries.selectById(tickets[0].itemId).executeAsOne().server_id + storeFailedCheckin( + eventSlug, + eventsAndCheckinLists[eventSlug] ?: return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.ERROR, + "No check-in list selected", + offline = true + ), + "ambiguous", + ticketid_cleaned, + type, + position = tickets[0].serverId, + item = itemServerId, + variation = tickets[0].variationServerId, + subevent = tickets[0].subEventServerId, + nonce = nonce, + ) + return TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.AMBIGUOUS) + } else { + // we don't have a matching ticket / orderposition, but it may be a reusable medium identifier val medium = db.reusableMediumQueries.selectForCheck( identifier = ticketid_cleaned, type = source_type, event_slugs = eventsAndCheckinLists.keys.toList(), ).executeAsOneOrNull()?.toModel() if (medium != null) { + val firstentry = eventsAndCheckinLists.entries.first() + if (!medium.active) { + // FIXME: note that this was an medium source + storeFailedCheckin(firstentry.key, firstentry.value, "invalid", ticketid, type, nonce = nonce) + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.INVALID, + "Medium not active", + offline = true + ) + } + + if (medium.expires?.isBefore(javaTimeNow()) == true) { + // FIXME: note that this was an medium source + storeFailedCheckin(firstentry.key, firstentry.value, "invalid", ticketid, type, nonce = nonce) + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.INVALID, + "Medium expired", + offline = true + ) + } + + // there may be multiple tickets / orderpositions linked to this medium + // e.g. a medium linked to tickets in multiple, different events or + // a medium that's linked to two tickets, one currently valid and one expired or in the future. val tickets = db.orderPositionQueries.selectByReusableMediumIdAndEventSlugs( reusablemedium_id = medium.id, event_slugs = eventsAndCheckinLists.keys.toList(), @@ -574,6 +643,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa type, nonce, allowQuestions, + mediumUsed = true ) } @@ -585,36 +655,209 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa nonce, allowQuestions, ) - } else if (tickets.size > 1) { - val eventSlug = db.orderQueries.selectById(tickets[0].orderId).executeAsOne().event_slug!! - val itemServerId = db.itemQueries.selectById(tickets[0].itemId).executeAsOne().server_id - storeFailedCheckin( - eventSlug, - eventsAndCheckinLists[eventSlug] ?: return TicketCheckProvider.CheckResult( - TicketCheckProvider.CheckResult.Type.ERROR, - "No check-in list selected", - offline = true - ), - "ambiguous", - ticketid_cleaned, - type, - position = tickets[0].serverId, - item = itemServerId, - variation = tickets[0].variationServerId, - subevent = tickets[0].subEventServerId, - nonce = nonce, - ) - return TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.AMBIGUOUS) } - return checkOfflineWithData(eventsAndCheckinLists, ticketid_cleaned, tickets, answers, ignore_unpaid, type, nonce = nonce, allowQuestions = allowQuestions) } - private fun checkOfflineWithData(eventsAndCheckinLists: Map, secret: String, tickets: List, answers: List?, ignore_unpaid: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, allowQuestions: Boolean): TicketCheckProvider.CheckResult { + data class PositionFilteringError( + val position: OrderPositionModel, + val eventSlug: String, + val list: CheckInList?, + val error: TicketCheckProvider.CheckResult.Type, + val message: String? = null + ) + + private fun filterPositions(eventsAndCheckinLists: Map, positions: List): Pair, List> { + var results = mutableListOf() + val errors = mutableListOf() + positions.forEach { position -> + val order = db.orderQueries.selectById(position.orderId).executeAsOne().toModel() + + val eventSlug = order.eventSlug + val event = db.eventQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() + if (event == null) { + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Event not found")) + return@forEach + } + + val listId = eventsAndCheckinLists[eventSlug] + if (listId == null) { + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "No check-in list selected")) + return@forEach + } + + val list = db.checkInListQueries.selectByServerIdAndEventSlug( + server_id = listId, + event_slug = eventSlug, + ).executeAsOneOrNull()?.toModel() + + if (list == null) { + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Check-in list not found")) + return@forEach + } + + // server side: 3a. + var resultingPositions = mutableSetOf(position) + if (list.addonMatch) { + // Add-on matching, as per spec, but only if we have data, it's impossible in data-less mode + val candidates = mutableListOf() + + val orderPositions = db.orderPositionQueries.selectForOrder(order.id).executeAsList() + .map { it.toModel() } + candidates.addAll(orderPositions.filter { + it.addonToServerId == position.serverId + }) + // server side: 3b. + val filteredCandidates = if (!list.allItems) { + val items = db.checkInListQueries.selectItemIdsForList(list.id) + .executeAsList() + .map { + // Not-null assertion needed for SQLite + it.id!! + } + .toHashSet() + candidates.filter { candidate -> + val candidateItem = + db.itemQueries.selectById(candidate.itemId).executeAsOne() + items.contains(candidateItem.id) + } + } else { + // This is a useless configuration that the backend won't allow, but we'll still handle + // it here for completeness + candidates + } + + if (filteredCandidates.isEmpty()) { + errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.PRODUCT)) + } else if (filteredCandidates.size > 1) { + errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.AMBIGUOUS)) + } else { + resultingPositions.add(filteredCandidates[0]) + } + } + + results.addAll(resultingPositions) + } + + // server side: 3c. + if (results.size > 1) { + val nowOdt = javaTimeNow() + results = results.filter { op -> + (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) + }.toMutableList() + } + + // None of the positions is valid today or has the correct product, too bad! + // We try to improve the error message by selecting the product that will "work next" or - if none matches - "worked last". + if (results.isEmpty()) { + val nowOdt = javaTimeNow() + var nearestCandidate: OrderPositionModel? = null + + positions.forEach { + if (it.validFrom != null && + (it.validFrom > nowOdt || + (nearestCandidate != null && it.validFrom < nearestCandidate.validFrom))) { + nearestCandidate = it + } + } + + if (nearestCandidate == null) { + positions.forEach { + if (it.validUntil != null && + (it.validUntil < nowOdt || + (nearestCandidate != null && it.validUntil > nearestCandidate.validUntil))) { + nearestCandidate = it + } + } + } + + if (nearestCandidate != null) { + val order = db.orderQueries.selectById(nearestCandidate.orderId).executeAsOne().toModel() + + val eventSlug = order.eventSlug + val event = db.eventQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() + if (event == null) { + return Pair(listOf(), listOf(PositionFilteringError(nearestCandidate, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Event not found"))) + } + + val listId = eventsAndCheckinLists[eventSlug] + if (listId == null) { + return Pair(listOf(), listOf(PositionFilteringError(nearestCandidate, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "No check-in list selected"))) + } + + val list = db.checkInListQueries.selectByServerIdAndEventSlug( + server_id = listId, + event_slug = eventSlug, + ).executeAsOneOrNull()?.toModel() + + if (list == null) { + return Pair(listOf(), listOf(PositionFilteringError(nearestCandidate, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Check-in list not found"))) + } + + return Pair(listOf(), listOf(PositionFilteringError(nearestCandidate, eventSlug, list, TicketCheckProvider.CheckResult.Type.INVALID_TIME))) + } + } + + return Pair(results, errors) + } + + private fun checkOfflineWithData( + eventsAndCheckinLists: Map, + secret: String, + tickets: List, + answers: List?, + ignore_unpaid: Boolean, + type: TicketCheckProvider.CheckInType, + nonce: String?, + allowQuestions: Boolean, + mediumUsed: Boolean + ): TicketCheckProvider.CheckResult { // !!! When extending this, also extend checkOfflineWithoutData !!! val dt = now() - val order = db.orderQueries.selectById(tickets[0].orderId).executeAsOne().toModel() - val item = db.itemQueries.selectById(tickets[0].itemId).executeAsOne().toModel() + val (positions, err) = filterPositions(eventsAndCheckinLists, tickets) + if (err.isNotEmpty()) { + val firstError = err.first() + val order = db.orderQueries.selectById(firstError.position.orderId).executeAsOne().toModel() + val item = db.itemQueries.selectById(firstError.position.itemId).executeAsOne().toModel() + if (firstError.list != null) { + when (firstError.error) { + TicketCheckProvider.CheckResult.Type.PRODUCT -> + storeFailedCheckin(firstError.eventSlug, firstError.list.serverId, "product", secret, type, position = firstError.position.serverId, item = item.serverId, variation = firstError.position.variationServerId, subevent = firstError.position.subEventServerId, nonce = nonce) + TicketCheckProvider.CheckResult.Type.AMBIGUOUS -> + storeFailedCheckin(firstError.eventSlug, firstError.list.serverId, "ambiguous", secret, type, position = firstError.position.serverId, item = item.serverId, variation = firstError.position.variationServerId, subevent = firstError.position.subEventServerId, nonce = nonce) + TicketCheckProvider.CheckResult.Type.INVALID_TIME -> + storeFailedCheckin(firstError.eventSlug, firstError.list.serverId, "invalid_time", secret, type, position = firstError.position.serverId, item = item.serverId, variation = firstError.position.variationServerId, subevent = firstError.position.subEventServerId, nonce = nonce) + else -> {} + } + } + + return TicketCheckProvider.CheckResult( + firstError.error, + firstError.message, + offline = true + ).apply { + orderCode = order.code + positionId = firstError.position.positionId + } + } + if (positions.isEmpty()) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.ERROR, + "No matching ticket found", + offline = true + ) + } + if (positions.size > 1) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.AMBIGUOUS, + offline = true + ) + } + + val position = positions[0] + + val order = db.orderQueries.selectById(position.orderId).executeAsOne().toModel() + val item = db.itemQueries.selectById(position.itemId).executeAsOne().toModel() val eventSlug = order.eventSlug val event = db.eventQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() @@ -627,43 +870,6 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ).executeAsOneOrNull()?.toModel() ?: return TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.ERROR, "Check-in list not found", offline = true) - val position = if (list.addonMatch) { - // Add-on matching, as per spec, but only if we have data, it's impossible in data-less mode - val candidates = mutableListOf(tickets[0]) - - val positions = db.orderPositionQueries.selectForOrder(order.id).executeAsList().map { it.toModel() } - candidates.addAll(positions.filter { - it.addonToServerId == tickets[0].serverId - }) - val filteredCandidates = if (!list.allItems) { - val items = db.checkInListQueries.selectItemIdsForList(list.id) - .executeAsList() - .map { - // Not-null assertion needed for SQLite - it.id!! - } - .toHashSet() - candidates.filter { candidate -> - val candidateItem = db.itemQueries.selectById(candidate.itemId).executeAsOne() - items.contains(candidateItem.id) - } - } else { - // This is a useless configuration that the backend won't allow, but we'll still handle - // it here for completeness - candidates - } - if (filteredCandidates.isEmpty()) { - storeFailedCheckin(eventSlug, list.serverId, "product", secret, type, position = tickets[0].serverId, item = item.serverId, variation = tickets[0].variationServerId, subevent = tickets[0].subEventServerId, nonce = nonce) - return TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.PRODUCT, offline = true) - } else if (filteredCandidates.size > 1) { - storeFailedCheckin(eventSlug, list.serverId, "ambiguous", secret, type, position = tickets[0].serverId, item = item.serverId, variation = tickets[0].variationServerId, subevent = tickets[0].subEventServerId, nonce = nonce) - return TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, offline = true) - } - filteredCandidates[0] - } else { - tickets[0] - } - val positionItem = if (position.id == tickets[0].id) { item } else { @@ -781,6 +987,28 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa return res } + val settings = db.settingsQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() + val reusableMediaUsageEnforced = (settings?.json?.optBoolean("reusable_media_usage_enforced", false) == true) + + val hasLinkedReusableMedium = + db.reusableMediumQueries.selectByLinkedOrderPosition(position.id) + .executeAsList().isNotEmpty() + + if (positionItem.mediaPolicy != MediaPolicy.NONE && positionItem.mediaType != ReusableMediaType.NONE && !mediumUsed) { + if (!hasLinkedReusableMedium) { + res.type = TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE + res.isCheckinAllowed = false + res.reasonExplanation = "This ticket needs to be exchanged, but this isn't possible while offline" + storeFailedCheckin(eventSlug, list.serverId, "exchange", position.secret!!, type, position = position.serverId, item = positionItem.serverId, variation = position.variationServerId, subevent = position.subEventServerId, nonce = nonce) + return res + } else if (reusableMediaUsageEnforced) { + res.type = TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED + res.isCheckinAllowed = false + storeFailedCheckin(eventSlug, list.serverId, "already_exchanged", position.secret!!, type, position = position.serverId, item = positionItem.serverId, variation = position.variationServerId, subevent = position.subEventServerId, nonce = nonce) + return res + } + } + // !!! When extending this, also extend checkOfflineWithoutData !!! val rules = list.rules diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt index 6e26db3c..7af71942 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/OnlineCheckProvider.kt @@ -8,7 +8,9 @@ import eu.pretix.libpretixsync.api.PretixApi import eu.pretix.libpretixsync.api.TimeoutApiException import eu.pretix.libpretixsync.config.ConfigStore import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy import eu.pretix.libpretixsync.db.NonceGenerator +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.db.toModel import eu.pretix.libpretixsync.sqldelight.Question import eu.pretix.libpretixsync.sqldelight.SyncDatabase @@ -47,7 +49,9 @@ class OnlineCheckProvider( with_badge_data: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, - allowQuestions: Boolean + allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val ticketid_cleaned = cleanInput(ticketid, source_type) val nonce_cleaned = nonce ?: NonceGenerator.nextNonce() @@ -70,6 +74,8 @@ class OnlineCheckProvider( source_type, callTimeout = if (fallback != null) fallbackTimeout.toLong() else null, questions_supported = allowQuestions, + exchange_medium_type = exchange_medium_type, + exchange_medium_identifier = exchange_medium_identifier, ) } else { if (eventsAndCheckinLists.size != 1) throw CheckException("Multi-event scan not supported by server.") @@ -124,6 +130,16 @@ class OnlineCheckProvider( required_answers.add(TicketCheckProvider.QuestionAnswer(question, q.toString(), "")) } res.requiredAnswers = required_answers + } else if ("exchange" == status){ + res.type = TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED + try { + res.requiredMediaPolicy = + MediaPolicy.getByServerName(response.optString("media_policy")) + res.requiredMediaType = + ReusableMediaType.getByServerName(response.optString("media_type")) + } catch (_: IllegalArgumentException) { + // silently fall back to null + } } else { val reason = response.optString("reason") if ("already_redeemed" == reason) { @@ -163,6 +179,8 @@ class OnlineCheckProvider( res.isCheckinAllowed = includePending && response.has("position") && response.getJSONObject("position").optString("order__status", "n") == "n" } else if ("product" == reason) { res.type = TicketCheckProvider.CheckResult.Type.PRODUCT + } else if ("already_exchanged" == reason) { + res.type = TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED } else { res.type = TicketCheckProvider.CheckResult.Type.ERROR } diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt index 4998168d..b70c4e6d 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/ProxyCheckProvider.kt @@ -109,7 +109,9 @@ class ProxyCheckProvider(private val config: ConfigStore, httpClientFactory: Htt with_badge_data: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, - allowQuestions: Boolean + allowQuestions: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): TicketCheckProvider.CheckResult { val answersInput = answers?.map { val questionModel = it.question as Question // TODO: Can we avoid the cast? @@ -130,6 +132,8 @@ class ProxyCheckProvider(private val config: ConfigStore, httpClientFactory: Htt type = type.name, allowQuestions = allowQuestions, nonce = nonce, + exchange_medium_type = exchange_medium_type, + exchange_medium_identifier = exchange_medium_identifier, ) return try { diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt index d2229836..791ffa97 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/TicketCheckProvider.kt @@ -2,6 +2,8 @@ package eu.pretix.libpretixsync.check import eu.pretix.libpretixsync.SentryInterface import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.MediaPolicy +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixsync.models.db.toModel import eu.pretix.libpretixsync.sqldelight.Question import eu.pretix.libpretixsync.models.Question as QuestionModel @@ -57,7 +59,8 @@ interface TicketCheckProvider { class CheckResult { enum class Type { INVALID, VALID, USED, ERROR, UNPAID, BLOCKED, INVALID_TIME, CANCELED, PRODUCT, RULES, - ANSWERS_REQUIRED, AMBIGUOUS, REVOKED, UNAPPROVED + ANSWERS_REQUIRED, AMBIGUOUS, REVOKED, UNAPPROVED, ALREADY_EXCHANGED, MEDIUM_INVALID, + MEDIUM_EXISTS, EXCHANGE_REQUIRED, EXCHANGE_REQUIRED_OFFLINE } var type: Type? = null @@ -80,6 +83,8 @@ interface TicketCheckProvider { var position: JSONObject? = null var eventSlug: String? = null var offline: Boolean = false + var requiredMediaPolicy: MediaPolicy? = null + var requiredMediaType: ReusableMediaType? = null constructor(type: Type?, message: String?, offline: Boolean = false) { this.type = type @@ -157,7 +162,20 @@ interface TicketCheckProvider { class StatusResult(var eventName: String?, var totalTickets: Int, var alreadyScanned: Int, var currentlyInside: Int?, var items: List?) { } - fun check(eventsAndCheckinLists: Map, ticketid: String, source_type: String, answers: List?, ignore_unpaid: Boolean, with_badge_data: Boolean, type: CheckInType, nonce: String? = null, allowQuestions: Boolean = true): CheckResult + fun check( + eventsAndCheckinLists: Map, + ticketid: String, + source_type: String, + answers: List?, + ignore_unpaid: Boolean, + with_badge_data: Boolean, + type: CheckInType, + nonce: String? = null, + allowQuestions: Boolean = true, + exchange_medium_type: String? = null, + exchange_medium_identifier: String? = null, + ): CheckResult + fun check(eventsAndCheckinLists: Map, ticketid: String): CheckResult @Throws(CheckException::class) fun search(eventsAndCheckinLists: Map, query: String, page: Int): List diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt index 6e034678..89158905 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/MediaPolicy.kt @@ -1,8 +1,15 @@ package eu.pretix.libpretixsync.db -enum class MediaPolicy { - NONE, - REUSE, - NEW, - REUSE_OR_NEW, -} +enum class MediaPolicy(val serverName: String?) { + NONE(null), + NEW("new"), + REUSE("reuse"), + REUSE_OR_NEW("reuse_or_new"), + APPEND("append"), + APPEND_OR_NEW("append_or_new"); + + companion object { + private val map = entries.associateBy(MediaPolicy::serverName) + fun getByServerName(serverName: String?) = map[serverName] + } +} \ No newline at end of file diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java deleted file mode 100644 index c4377bb8..00000000 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.java +++ /dev/null @@ -1,20 +0,0 @@ -package eu.pretix.libpretixsync.db; - -public enum ReusableMediaType { - NONE(null), - BARCODE("barcode"), - NFC_UID("nfc_uid"), - - NFC_MF0AES("nfc_mf0aes"), - UNSUPPORTED(null); - - public final String serverName; - - private ReusableMediaType(String serverName) { - this.serverName = serverName; - } - - public boolean isNfcBased() { - return this.serverName.startsWith("nfc_"); - } -} diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt new file mode 100644 index 00000000..bed4d0da --- /dev/null +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/db/ReusableMediaType.kt @@ -0,0 +1,19 @@ +package eu.pretix.libpretixsync.db + +enum class ReusableMediaType(val serverName: String?) { + NONE(null), + BARCODE("barcode"), + NFC_UID("nfc_uid"), + + NFC_MF0AES("nfc_mf0aes"), + UNSUPPORTED("unsupported"); + + fun isNfcBased(): Boolean { + return this.serverName?.startsWith("nfc_") ?: false; + } + + companion object { + private val map = ReusableMediaType.entries.associateBy(ReusableMediaType::serverName) + fun getByServerName(serverName: String?) = map[serverName] + } +} diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/ReusableMedium.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/ReusableMedium.kt index d99638a9..4d0dde66 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/ReusableMedium.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/ReusableMedium.kt @@ -1,11 +1,13 @@ package eu.pretix.libpretixsync.models +import java.time.OffsetDateTime + data class ReusableMedium( val id: Long, val serverId: Long?, val active: Boolean, val customerId: Long?, - val expires: String?, + val expires: OffsetDateTime?, val identifier: String?, val linkedGiftCardId: Long?, val type: String?, diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt index 045803ec..7307ed9d 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ItemExtensions.kt @@ -208,9 +208,9 @@ private fun parseHasFreePrice(json: JSONObject): Boolean { private fun parseMediaPolicy(json: JSONObject): MediaPolicy { return try { val mp: String = json.optString("media_policy") ?: return MediaPolicy.NONE - if (mp == "reuse") return MediaPolicy.REUSE - if (mp == "new") return MediaPolicy.NEW - if (mp == "reuse_or_new") MediaPolicy.REUSE_OR_NEW else MediaPolicy.NONE + MediaPolicy.getByServerName(mp) ?: return MediaPolicy.NONE + } catch (_: IllegalArgumentException) { + MediaPolicy.NONE } catch (e: JSONException) { e.printStackTrace() MediaPolicy.NONE @@ -220,9 +220,7 @@ private fun parseMediaPolicy(json: JSONObject): MediaPolicy { private fun parseMediaType(json: JSONObject): ReusableMediaType { return try { val mp: String = json.optString("media_type") ?: return ReusableMediaType.NONE - if (mp == "barcode") return ReusableMediaType.BARCODE - if (mp == "nfc_uid") return ReusableMediaType.NFC_UID - if (mp == "nfc_mf0aes") ReusableMediaType.NFC_MF0AES else ReusableMediaType.UNSUPPORTED + ReusableMediaType.getByServerName(mp) ?: ReusableMediaType.UNSUPPORTED } catch (e: JSONException) { e.printStackTrace() ReusableMediaType.NONE diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMedium.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMediumExtensions.kt similarity index 71% rename from libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMedium.kt rename to libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMediumExtensions.kt index c675e132..c6ddcace 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMedium.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/ReusableMediumExtensions.kt @@ -1,15 +1,19 @@ package eu.pretix.libpretixsync.models.db import eu.pretix.libpretixsync.sqldelight.ReusableMedium +import eu.pretix.libpretixsync.sqldelight.SafeOffsetDateTimeMapper +import org.json.JSONObject import eu.pretix.libpretixsync.models.ReusableMedium as ReusableMediumModel fun ReusableMedium.toModel(): ReusableMediumModel { + val json = JSONObject(this.json_data!!) + return ReusableMediumModel( id = this.id, serverId = this.server_id!!, active = this.active, customerId = this.customer_id, - expires = this.expires, + expires = SafeOffsetDateTimeMapper.decode(json, "expires"), identifier = this.identifier, linkedGiftCardId = this.linked_giftcard_id, type = this.type, diff --git a/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt b/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt index cba082f4..05628a3b 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt @@ -5,10 +5,13 @@ import app.cash.sqldelight.db.QueryResult import eu.pretix.libpretixsync.api.ApiException import eu.pretix.libpretixsync.api.PretixApi import eu.pretix.libpretixsync.api.ResourceNotModified +import eu.pretix.libpretixsync.sqldelight.Migrations import eu.pretix.libpretixsync.sqldelight.ResourceSyncStatus import eu.pretix.libpretixsync.sqldelight.ReusableMedium import eu.pretix.libpretixsync.sqldelight.SyncDatabase import eu.pretix.libpretixsync.sync.SyncManager.ProgressFeedback +import eu.pretix.libpretixsync.utils.JSONUtils +import org.joda.time.format.ISODateTimeFormat import org.json.JSONException import org.json.JSONObject import java.io.UnsupportedEncodingException @@ -62,11 +65,17 @@ class ReusableMediaSyncAdapter( } override fun insert(jsonobj: JSONObject) { + val expires = if (!jsonobj.isNull("expires")) { + ISODateTimeFormat.dateTimeParser().parseDateTime(jsonobj.getString("expires")).toDate() + } else { + null + } + val rmId = db.reusableMediumQueries.transactionWithResult { db.reusableMediumQueries.insert( active = jsonobj.getBoolean("active"), customer_id = jsonobj.optLong("customer"), - expires = jsonobj.optString("expires"), + expires = expires, identifier = jsonobj.getString("identifier"), json_data = jsonobj.toString(), linked_giftcard_id = jsonobj.optLong("linked_giftcard"), @@ -88,10 +97,16 @@ class ReusableMediaSyncAdapter( } .toSet() + val expires = if (!jsonobj.isNull("expires")) { + ISODateTimeFormat.dateTimeParser().parseDateTime(jsonobj.getString("expires")).toDate() + } else { + null + } + db.reusableMediumQueries.updateFromJson( active = jsonobj.getBoolean("active"), customer_id = jsonobj.optLong("customer"), - expires = jsonobj.optString("expires"), + expires = expires, identifier = jsonobj.getString("identifier"), json_data = jsonobj.toString(), linked_giftcard_id = jsonobj.optLong("linked_giftcard"), @@ -117,9 +132,9 @@ class ReusableMediaSyncAdapter( } val newIds = if (orderpositionids.isNotEmpty()) { - db.orderPositionQueries.selectByReusableMediumId( - reusablemedium_id = rmId, - ).executeAsList().map { it.id }.toSet() + orderpositionids.mapNotNull { + db.orderPositionQueries.selectByServerId(it).executeAsOneOrNull()?.id + }.toSet() } else { emptySet() } @@ -293,4 +308,21 @@ class ReusableMediaSyncAdapter( return d } + @Throws(JSONException::class) + fun standaloneRefreshFromJSON(data: JSONObject) { + val known = db.reusableMediumQueries.selectByServerId(data.getLong("id")).executeAsOneOrNull() + + // Store object + data.put("__libpretixsync_dbversion", Migrations.CURRENT_VERSION) + data.put("__libpretixsync_syncCycleId", syncCycleId) + if (known == null) { + insert(data) + } else { + val old = JSONObject(known.json_data!!) + if (!JSONUtils.similar(data, old)) { + update(known, data) + } + } + } + } diff --git a/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq b/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq index ede9a5ad..d4a71b8f 100644 --- a/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq +++ b/libpretixsync/src/main/sqldelight/common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq @@ -13,15 +13,21 @@ FROM ReusableMedium WHERE server_id IN ?; selectForCheck: -SELECT ReusableMedium.* +SELECT DISTINCT ReusableMedium.* FROM ReusableMedium -LEFT JOIN ReusableMedium_OrderPosition ON ReusableMedium_OrderPosition.OrderPositionId = ReusableMedium.id +LEFT JOIN ReusableMedium_OrderPosition ON ReusableMedium_OrderPosition.ReusableMediumId = ReusableMedium.id LEFT JOIN OrderPosition ON ReusableMedium_OrderPosition.OrderPositionId = OrderPosition.id LEFT JOIN orders ON OrderPosition.order_ref = orders.id WHERE ReusableMedium.identifier = :identifier AND ReusableMedium.type = :type AND orders.event_slug IN :event_slugs; +selectByLinkedOrderPosition: +SELECT ReusableMedium.* +FROM ReusableMedium +LEFT JOIN ReusableMedium_OrderPosition ON ReusableMedium_OrderPosition.ReusableMediumId = ReusableMedium.id +WHERE OrderPositionId = :order_position_id; + deleteByServerId: DELETE FROM ReusableMedium WHERE server_id = ?; diff --git a/libpretixsync/src/main/sqldelight/common/migrations/116.sqm b/libpretixsync/src/main/sqldelight/common/migrations/116.sqm new file mode 100644 index 00000000..48b23aff --- /dev/null +++ b/libpretixsync/src/main/sqldelight/common/migrations/116.sqm @@ -0,0 +1 @@ +UPDATE ReusableMedium SET expires = NULL WHERE expires = 'null'; \ No newline at end of file diff --git a/libpretixsync/src/main/sqldelight/postgres/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq b/libpretixsync/src/main/sqldelight/postgres/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq index 31d6dc84..8ba1ecb2 100644 --- a/libpretixsync/src/main/sqldelight/postgres/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq +++ b/libpretixsync/src/main/sqldelight/postgres/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq @@ -4,7 +4,7 @@ CREATE TABLE ReusableMedium ( id serial AS Long PRIMARY KEY NOT NULL, active boolean NOT NULL, customer_id bigint, - expires character varying(255), + expires DATE AS Date, identifier character varying(255), json_data text, linked_giftcard_id bigint, diff --git a/libpretixsync/src/main/sqldelight/sqlite/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq b/libpretixsync/src/main/sqldelight/sqlite/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq index 11ebfedb..d850eb8f 100644 --- a/libpretixsync/src/main/sqldelight/sqlite/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq +++ b/libpretixsync/src/main/sqldelight/sqlite/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq @@ -1,10 +1,11 @@ +import java.util.Date; import kotlin.Boolean; CREATE TABLE ReusableMedium ( id INTEGER PRIMARY KEY AUTOINCREMENT, active INTEGER AS Boolean NOT NULL, customer_id INTEGER, - expires TEXT, + expires TEXT AS Date, identifier TEXT, json_data TEXT, linked_giftcard_id INTEGER, diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt new file mode 100644 index 00000000..1269b2bf --- /dev/null +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -0,0 +1,140 @@ +package eu.pretix.libpretixsync.check + +import eu.pretix.libpretixsync.db.BaseDatabaseTest +import eu.pretix.libpretixsync.sync.CheckInListSyncAdapter +import eu.pretix.libpretixsync.sync.EventSyncAdapter +import eu.pretix.libpretixsync.sync.ItemSyncAdapter +import eu.pretix.libpretixsync.sync.OrderSyncAdapter +import eu.pretix.libpretixsync.sync.ReusableMediaSyncAdapter +import eu.pretix.pretixscan.scanproxy.tests.test.FakeConfigStore +import eu.pretix.pretixscan.scanproxy.tests.test.FakeFileStorage +import eu.pretix.pretixscan.scanproxy.tests.test.FakePretixApi +import eu.pretix.pretixscan.scanproxy.tests.test.jsonResource +import org.joda.time.format.ISODateTimeFormat +import org.junit.Before +import org.junit.Test + +import org.junit.Assert.assertEquals + +class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { + private var configStore: FakeConfigStore? = null + private var fakeApi: FakePretixApi? = null + private var p: AsyncCheckProvider? = null + + @Before + fun setUpFakes() { + configStore = FakeConfigStore("mtrmt", "event1") + fakeApi = FakePretixApi("mtrmt") + p = AsyncCheckProvider(configStore!!, db) + + EventSyncAdapter(db, "event1", "event1", fakeApi!!, "", null).standaloneRefreshFromJSON(jsonResource("events/rmevent1.json")) + EventSyncAdapter(db, "event2", "event2", fakeApi!!, "", null).standaloneRefreshFromJSON(jsonResource("events/rmevent2.json")) + ItemSyncAdapter(db, FakeFileStorage(), "event1", fakeApi!!, "", null).standaloneRefreshFromJSON(jsonResource("items/rmevent1-item1.json")) + ItemSyncAdapter(db, FakeFileStorage(), "event2", fakeApi!!, "", null).standaloneRefreshFromJSON(jsonResource("items/rmevent2-item1.json")) + CheckInListSyncAdapter(db, FakeFileStorage(), "event1", fakeApi!!, "", null, 0).standaloneRefreshFromJSON( + jsonResource("checkinlists/rmevent1-list1.json") + ) + CheckInListSyncAdapter(db, FakeFileStorage(), "event2", fakeApi!!, "", null, 0).standaloneRefreshFromJSON( + jsonResource("checkinlists/rmevent2-list1.json") + ) + + val osa = OrderSyncAdapter(db, FakeFileStorage(), "event1", 0, true, false, fakeApi!!, "", null) + osa.standaloneRefreshFromJSON(jsonResource("orders/rmevent1-order1.json")) + val osa2 = OrderSyncAdapter(db, FakeFileStorage(), "event2", 0, true, false, fakeApi!!, "", null) + osa2.standaloneRefreshFromJSON(jsonResource("orders/rmevent2-order1.json")) + + val rmsa = ReusableMediaSyncAdapter(db, FakeFileStorage(), fakeApi!!, "", null) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium1.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium2.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium3.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium4.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium5.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium6.json")) + rmsa.standaloneRefreshFromJSON(jsonResource("reusablemedia/mtrmt-medium7.json")) + } + + @Test + fun testMediumNotActive() { + val r = p!!.check(mapOf("event1" to 35L), "5555") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID, r.type) + } + + @Test + fun testMediumExpired() { + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-01-01T00:00:01.000Z")) + val r = p!!.check(mapOf("event1" to 35L), "6666") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID, r.type) + } + + @Test + fun testTwoTicketsTimesOverlappingSameEvent() { + val r = p!!.check(mapOf("event1" to 35L), "1111") + assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + } + + @Test + fun testTwoTicketsTimesOverlappingDifferentEvents() { + val r = p!!.check(mapOf("event1" to 35L), "2222") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("Regular ticket", r.ticket) + assertEquals("W0JKM", r.orderCode) + assertEquals(1L, r.positionId) + } + + @Test + fun testTwoTicketsTimesNonOverlappingSameEvent() { + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-01-01T00:00:01.000Z")) + var r = p!!.check(mapOf("event1" to 35L), "3333") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("W0JKM", r.orderCode) + assertEquals(1L, r.positionId) + + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2027-01-01T00:00:01.000Z")) + r = p!!.check(mapOf("event1" to 35L), "3333") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("W0JKM", r.orderCode) + assertEquals(3L, r.positionId) + } + + @Test + fun testTwoTicketsTimesNonOverlappingDifferentEvents() { + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-01-05T00:00:01.000Z")) + var r = p!!.check(mapOf("event1" to 35L), "4444") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID_TIME, r.type) + + r = p!!.check(mapOf("event2" to 36L), "4444") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2027-01-05T00:00:01.000Z")) + r = p!!.check(mapOf("event1" to 35L), "4444") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + + r = p!!.check(mapOf("event2" to 36L), "4444") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID_TIME, r.type) + } + + @Test + fun testTwoTicketsTimesNonOverlappingSpaceBetweenSameEvent() { + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-01-01T00:00:01.000Z")) + var r = p!!.check(mapOf("event1" to 35L), "7777") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("W0JKM-6", r.orderCodeAndPositionId()) + + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-09-01T00:00:01.000Z")) + r = p!!.check(mapOf("event1" to 35L), "7777") + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("W0JKM-7", r.orderCodeAndPositionId()) + + // use the candidate that will "work next" + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2026-08-01T00:00:01.000Z")) + r = p!!.check(mapOf("event1" to 35L), "7777") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID_TIME, r.type) + assertEquals("W0JKM-7", r.orderCodeAndPositionId()) + + // no candidate in the future, use closest from the past + p!!.setNow(ISODateTimeFormat.dateTime().parseDateTime("2027-01-01T00:00:01.000Z")) + r = p!!.check(mapOf("event1" to 35L), "7777") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID_TIME, r.type) + assertEquals("W0JKM-7", r.orderCodeAndPositionId()) + } +} diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/db/BaseDatabaseTest.java b/libpretixsync/src/test/java/eu/pretix/libpretixsync/db/BaseDatabaseTest.java index cc8356ee..522908c9 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/db/BaseDatabaseTest.java +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/db/BaseDatabaseTest.java @@ -11,6 +11,7 @@ import eu.pretix.libpretixsync.sqldelight.Receipt; import eu.pretix.libpretixsync.sqldelight.ReceiptLine; import eu.pretix.libpretixsync.sqldelight.ReceiptPayment; +import eu.pretix.libpretixsync.sqldelight.ReusableMedium; import eu.pretix.libpretixsync.sqldelight.SubEvent; import eu.pretix.libpretixsync.sqldelight.SyncDatabase; import org.junit.After; @@ -98,6 +99,9 @@ public void setUpDb() throws NoSuchAlgorithmException { new ReceiptPayment.Adapter( bigDecimalAdapter ), + new ReusableMedium.Adapter( + dateAdapter + ), new SubEvent.Adapter( dateAdapter, dateAdapter diff --git a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakeConfigStore.kt b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakeConfigStore.kt index 8dcb4eb7..6c642c3a 100644 --- a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakeConfigStore.kt +++ b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakeConfigStore.kt @@ -4,7 +4,7 @@ import eu.pretix.libpretixsync.config.ConfigStore import eu.pretix.libpretixsync.api.PretixApi import org.json.JSONObject -class FakeConfigStore : ConfigStore { +class FakeConfigStore(var organizer_slug: String = "demo", var event_slug: String = "demo") : ConfigStore { private var last_download: Long = 0 private var last_sync: Long = 0 private var last_cleanup: Long = 0 @@ -70,11 +70,11 @@ class FakeConfigStore : ConfigStore { } override fun getOrganizerSlug(): String { - return "demo" + return organizer_slug } val eventSlug: String - get() = "demo" + get() = event_slug val subEventId: Long? get() = null @@ -136,7 +136,7 @@ class FakeConfigStore : ConfigStore { } override fun getSynchronizedEvents(): List { - return listOf("demo") + return listOf(event_slug) } override fun getSelectedSubeventForEvent(event: String): Long? { diff --git a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt index 284b4182..de7351a9 100644 --- a/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt +++ b/libpretixsync/src/testFixtures/java/eu/pretix/pretixscan/scanproxy/tests/test/FakePretixApi.kt @@ -8,7 +8,7 @@ import org.json.JSONException import org.json.JSONObject import java.io.File -class FakePretixApi : PretixApi("http://1.1.1.1/", "a", "demo", 1, DefaultHttpClientFactory()) { +class FakePretixApi(var orgaSlug: String = "demo") : PretixApi("http://1.1.1.1/", "a", orgaSlug, 1, DefaultHttpClientFactory()) { val redeemResponses: MutableList<(() -> ApiResponse)> = ArrayList() val statusResponses: MutableList<(() -> ApiResponse)> = ArrayList() val searchResponses: MutableList<(() -> ApiResponse)> = ArrayList() @@ -42,7 +42,9 @@ class FakePretixApi : PretixApi("http://1.1.1.1/", "a", "demo", 1, DefaultHttpCl type: String?, source_type: String?, callTimeout: Long?, - questions_supported: Boolean + questions_supported: Boolean, + exchange_medium_type: String?, + exchange_medium_identifier: String?, ): ApiResponse { redeemRequestSecret = secret redeemRequestDatetime = datetime diff --git a/libpretixsync/src/testFixtures/resources/checkinlists/rmevent1-list1.json b/libpretixsync/src/testFixtures/resources/checkinlists/rmevent1-list1.json new file mode 100644 index 00000000..f4aa1bb7 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/checkinlists/rmevent1-list1.json @@ -0,0 +1,17 @@ +{ + "id": 35, + "name": "Default", + "all_products": true, + "limit_products": [], + "subevent": null, + "checkin_count": 0, + "position_count": 8, + "include_pending": false, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, + "rules": {}, + "exit_all_at": null, + "addon_match": false, + "ignore_in_statistics": false, + "consider_tickets_used": true +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/checkinlists/rmevent2-list1.json b/libpretixsync/src/testFixtures/resources/checkinlists/rmevent2-list1.json new file mode 100644 index 00000000..9d6d31ce --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/checkinlists/rmevent2-list1.json @@ -0,0 +1,17 @@ +{ + "id": 36, + "name": "Default", + "all_products": true, + "limit_products": [], + "subevent": null, + "checkin_count": 0, + "position_count": 8, + "include_pending": false, + "allow_multiple_entries": false, + "allow_entry_after_exit": true, + "rules": {}, + "exit_all_at": null, + "addon_match": false, + "ignore_in_statistics": false, + "consider_tickets_used": true +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/events/rmevent1.json b/libpretixsync/src/testFixtures/resources/events/rmevent1.json new file mode 100644 index 00000000..cfa3b932 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/events/rmevent1.json @@ -0,0 +1,38 @@ +{ + "name": { + "en": "Event 1" + }, + "slug": "event1", + "live": true, + "testmode": true, + "currency": "EUR", + "date_from": "2026-05-31T00:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": true, + "presale_start": null, + "presale_end": null, + "location": {}, + "geo_lat": null, + "geo_lon": null, + "has_subevents": false, + "meta_data": {}, + "seating_plan": null, + "plugins": [ + "pretix.plugins.sendmail", + "pretix.plugins.statistics", + "pretix.plugins.ticketoutputpdf" + ], + "seat_category_mapping": {}, + "timezone": "UTC", + "item_meta_properties": {}, + "valid_keys": { + "pretix_sig1": [] + }, + "all_sales_channels": true, + "limit_sales_channels": [], + "sales_channels": [ + "pretixpos", + "web" + ] +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/events/rmevent2.json b/libpretixsync/src/testFixtures/resources/events/rmevent2.json new file mode 100644 index 00000000..b1d11bab --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/events/rmevent2.json @@ -0,0 +1,38 @@ +{ + "name": { + "en": "Event 2" + }, + "slug": "event2", + "live": true, + "testmode": true, + "currency": "EUR", + "date_from": "2026-05-31T00:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": true, + "presale_start": null, + "presale_end": null, + "location": {}, + "geo_lat": null, + "geo_lon": null, + "has_subevents": false, + "meta_data": {}, + "seating_plan": null, + "plugins": [ + "pretix.plugins.sendmail", + "pretix.plugins.statistics", + "pretix.plugins.ticketoutputpdf" + ], + "seat_category_mapping": {}, + "timezone": "UTC", + "item_meta_properties": {}, + "valid_keys": { + "pretix_sig1": [] + }, + "all_sales_channels": true, + "limit_sales_channels": [], + "sales_channels": [ + "pretixpos", + "web" + ] +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/items/rmevent1-item1.json b/libpretixsync/src/testFixtures/resources/items/rmevent1-item1.json new file mode 100644 index 00000000..e8a1f092 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/items/rmevent1-item1.json @@ -0,0 +1,70 @@ +{ + "id": 214, + "category": null, + "name": { + "en": "Regular ticket" + }, + "internal_name": null, + "active": true, + "all_sales_channels": true, + "limit_sales_channels": [], + "description": null, + "default_price": "0.00", + "free_price": false, + "free_price_suggestion": null, + "tax_rate": "0.00", + "tax_rule": null, + "admission": true, + "personalized": true, + "position": 0, + "picture": null, + "available_from": null, + "available_from_mode": "hide", + "available_until": null, + "available_until_mode": "hide", + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "require_bundling": false, + "min_per_order": null, + "max_per_order": null, + "checkin_attention": false, + "checkin_text": null, + "has_variations": false, + "variations": [], + "addons": [], + "bundles": [], + "program_times": [], + "original_price": null, + "require_approval": false, + "generate_tickets": null, + "show_quota_left": null, + "hidden_if_available": null, + "hidden_if_item_available": null, + "hidden_if_item_available_mode": "hide", + "allow_waitinglist": true, + "issue_giftcard": false, + "meta_data": {}, + "require_membership": false, + "require_membership_types": [], + "require_membership_hidden": false, + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, + "validity_mode": null, + "validity_fixed_from": null, + "validity_fixed_until": null, + "validity_dynamic_duration_minutes": null, + "validity_dynamic_duration_hours": null, + "validity_dynamic_duration_days": null, + "validity_dynamic_duration_months": null, + "validity_dynamic_start_choice": false, + "validity_dynamic_start_choice_day_limit": null, + "media_policy": null, + "media_type": null, + "sales_channels": [ + "pretixpos", + "web" + ] +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/items/rmevent2-item1.json b/libpretixsync/src/testFixtures/resources/items/rmevent2-item1.json new file mode 100644 index 00000000..7da7626b --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/items/rmevent2-item1.json @@ -0,0 +1,70 @@ +{ + "id": 215, + "category": null, + "name": { + "en": "Regular ticket" + }, + "internal_name": null, + "active": true, + "all_sales_channels": true, + "limit_sales_channels": [], + "description": null, + "default_price": "0.00", + "free_price": false, + "free_price_suggestion": null, + "tax_rate": "0.00", + "tax_rule": null, + "admission": true, + "personalized": true, + "position": 0, + "picture": null, + "available_from": null, + "available_from_mode": "hide", + "available_until": null, + "available_until_mode": "hide", + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "require_bundling": false, + "min_per_order": null, + "max_per_order": null, + "checkin_attention": false, + "checkin_text": null, + "has_variations": false, + "variations": [], + "addons": [], + "bundles": [], + "program_times": [], + "original_price": null, + "require_approval": false, + "generate_tickets": null, + "show_quota_left": null, + "hidden_if_available": null, + "hidden_if_item_available": null, + "hidden_if_item_available_mode": "hide", + "allow_waitinglist": true, + "issue_giftcard": false, + "meta_data": {}, + "require_membership": false, + "require_membership_types": [], + "require_membership_hidden": false, + "grant_membership_type": null, + "grant_membership_duration_like_event": true, + "grant_membership_duration_days": 0, + "grant_membership_duration_months": 0, + "validity_mode": null, + "validity_fixed_from": null, + "validity_fixed_until": null, + "validity_dynamic_duration_minutes": null, + "validity_dynamic_duration_hours": null, + "validity_dynamic_duration_days": null, + "validity_dynamic_duration_months": null, + "validity_dynamic_start_choice": false, + "validity_dynamic_start_choice_day_limit": null, + "media_policy": null, + "media_type": null, + "sales_channels": [ + "pretixpos", + "web" + ] +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json b/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json new file mode 100644 index 00000000..a695bf72 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json @@ -0,0 +1,410 @@ +{ + "code": "W0JKM", + "event": "event1", + "status": "p", + "testmode": true, + "secret": "o7tf1o3679mrrnqh", + "email": "reusablemedia@example.org", + "phone": null, + "locale": "en", + "datetime": "2026-05-08T10:01:43.195739Z", + "expires": "2026-05-22T23:59:59Z", + "payment_date": "2026-05-08", + "payment_provider": "free", + "fees": [], + "total": "0.00", + "tax_rounding_mode": "line", + "comment": "", + "custom_followup_at": null, + "invoice_address": { + "last_modified": "2026-05-08T10:01:43.312471Z", + "is_business": false, + "company": "", + "name": "", + "name_parts": { + "_scheme": "given_family" + }, + "street": "", + "zipcode": "", + "city": "", + "country": "", + "state": "", + "vat_id": "", + "vat_id_validated": false, + "custom_field": null, + "internal_reference": "", + "transmission_type": "email", + "transmission_info": { + + } + }, + "positions": [ + { + "id": 18697, + "order": "W0JKM", + "positionid": 1, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "t85xbnppabyq3p282eb6cba35n8tgrce", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "TGKP7YTBVB", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18698, + "order": "W0JKM", + "positionid": 2, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "65uvfg8z857qz7ukn6zep3f3n6k55gu2", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "BEJR33RCFH", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18699, + "order": "W0JKM", + "positionid": 3, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "w9gjs2h6pd4gmcb9fcgks9wasm9c65vr", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "XMEDKVMPV8", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2027-01-01T00:00:00Z", + "valid_until": "2027-01-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18700, + "order": "W0JKM", + "positionid": 4, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "gadkgh8rf7rjfjsktynsk3mqqfyzj6uc", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "79TXQQ99VN", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18701, + "order": "W0JKM", + "positionid": 5, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "c2nktwvaeyypj3crkpetmbwzdd2u7avf", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "YHVBGJXWTX", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18702, + "order": "W0JKM", + "positionid": 6, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "cc5htsbwm3ue5mumnhrzbb48c6fbtu8p", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "XPJ8PCFWB9", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-05-01T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18703, + "order": "W0JKM", + "positionid": 7, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "ecqupkbgvahqp99d6t96arymgx4pm3n3", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "7STAAHLCMX", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-09-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18704, + "order": "W0JKM", + "positionid": 8, + "item": 214, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "rxve9d7z7gfx3nbbfkkqzw3azp9e8pq6", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "8GLGRUWEHP", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + } + ], + "downloads": [], + "checkin_attention": false, + "checkin_text": null, + "last_modified": "2026-05-08T10:08:45.099234Z", + "payments": [ + { + "local_id": 1, + "state": "confirmed", + "amount": "0.00", + "created": "2026-05-08T10:01:43.322983Z", + "payment_date": "2026-05-08T10:01:43.494978Z", + "provider": "free", + "payment_url": null, + "details": { + + } + } + ], + "refunds": [], + "require_approval": false, + "sales_channel": "web", + "url": "http://localhost/mtrmt/event1/order/W0JKM/o7tf1o3679mrrnqh/", + "customer": null, + "valid_if_pending": false, + "api_meta": { + + }, + "cancellation_date": null, + "plugin_data": { + + } +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/orders/rmevent2-order1.json b/libpretixsync/src/testFixtures/resources/orders/rmevent2-order1.json new file mode 100644 index 00000000..fcb5fbe4 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/orders/rmevent2-order1.json @@ -0,0 +1,410 @@ +{ + "code": "M0ERV", + "event": "event2", + "status": "p", + "testmode": true, + "secret": "1y737qahjedjvlwu", + "email": "reusablemedia@example.org", + "phone": null, + "locale": "en", + "datetime": "2026-05-08T10:02:16.959414Z", + "expires": "2026-05-22T23:59:59Z", + "payment_date": "2026-05-08", + "payment_provider": "free", + "fees": [], + "total": "0.00", + "tax_rounding_mode": "line", + "comment": "", + "custom_followup_at": null, + "invoice_address": { + "last_modified": "2026-05-08T10:02:17.066083Z", + "is_business": false, + "company": "", + "name": "", + "name_parts": { + "_scheme": "given_family" + }, + "street": "", + "zipcode": "", + "city": "", + "country": "", + "state": "", + "vat_id": "", + "vat_id_validated": false, + "custom_field": null, + "internal_reference": "", + "transmission_type": "email", + "transmission_info": { + + } + }, + "positions": [ + { + "id": 18705, + "order": "M0ERV", + "positionid": 1, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "phmr2gkcxkwy4e3fkcmp3tfhpde3trj6", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "DPN3JCTYYY", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18706, + "order": "M0ERV", + "positionid": 2, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "ex8h6rb2grwj4rf5mb7rdx8m779wgc3t", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "LANPENQHMP", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18707, + "order": "M0ERV", + "positionid": 3, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "8y3gnuugqjwqe3xkz8cwdacebug4a7xh", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "ZQ3NLUFBHL", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18708, + "order": "M0ERV", + "positionid": 4, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "pmjf8u7e88suuexwps795mtrtk3b2n8s", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "MU3XENFNYF", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18709, + "order": "M0ERV", + "positionid": 5, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "xcefzrwhjjxn3xcjp7wk8pxegdnhu9e3", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "XZT7C8M7V9", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18710, + "order": "M0ERV", + "positionid": 6, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "5pwbc9f5fz8rn8aj3vandnn58ysfg26e", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "JCGSBXHMLY", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18711, + "order": "M0ERV", + "positionid": 7, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "q93rr9bnwkfuhacfdd5fuecrt8tvhrzv", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "XMPUWBKAMA", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + }, + { + "id": 18712, + "order": "M0ERV", + "positionid": 8, + "item": 215, + "variation": null, + "price": "0.00", + "attendee_name": "", + "attendee_name_parts": { + "_scheme": "given_family" + }, + "company": null, + "street": null, + "zipcode": null, + "city": null, + "country": null, + "state": null, + "discount": null, + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "munxqzkyyztz7fr2b9z38jeeja3w4dqt", + "addon_to": null, + "subevent": null, + "checkins": [], + "print_logs": [], + "downloads": [], + "answers": [], + "tax_rule": null, + "pseudonymization_id": "PEJYAS7T9M", + "seat": null, + "canceled": false, + "tax_code": null, + "valid_from": null, + "valid_until": null, + "blocked": null, + "voucher_budget_use": null, + "plugin_data": { + + } + } + ], + "downloads": [], + "checkin_attention": false, + "checkin_text": null, + "last_modified": "2026-05-08T10:08:05.853507Z", + "payments": [ + { + "local_id": 1, + "state": "confirmed", + "amount": "0.00", + "created": "2026-05-08T10:02:17.074479Z", + "payment_date": "2026-05-08T10:02:17.266553Z", + "provider": "free", + "payment_url": null, + "details": { + + } + } + ], + "refunds": [], + "require_approval": false, + "sales_channel": "web", + "url": "http://localhost/mtrmt/event2/order/M0ERV/1y737qahjedjvlwu/", + "customer": null, + "valid_if_pending": false, + "api_meta": { + + }, + "cancellation_date": null, + "plugin_data": { + + } +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium1.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium1.json new file mode 100644 index 00000000..2d1d1a88 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium1.json @@ -0,0 +1,20 @@ +{ + "id": 27, + "organizer": "mtrmt", + "created": "2026-05-08T10:00:05.758077Z", + "updated": "2026-05-08T10:07:20.207322Z", + "type": "barcode", + "identifier": "1111", + "claim_token": null, + "label": null, + "active": true, + "expires": null, + "customer": null, + "linked_orderpositions": [18697, 18698], + "linked_giftcard": null, + "info": { + + }, + "notes": "Zwei Tickets mit überlappender Gültigkeit in gleicher Veranstaltung", + "linked_orderposition": null +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium2.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium2.json new file mode 100644 index 00000000..635448f2 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium2.json @@ -0,0 +1,20 @@ +{ + "id": 28, + "organizer": "mtrmt", + "created": "2026-05-08T10:00:45.370650Z", + "updated": "2026-05-08T10:08:17.718966Z", + "type": "barcode", + "identifier": "2222", + "claim_token": null, + "label": null, + "active": true, + "expires": null, + "customer": null, + "linked_orderpositions": [18697, 18705], + "linked_giftcard": null, + "info": { + + }, + "notes": "Zwei Tickets mit überlappender Gültigkeit in unterschiedlicher Veranstaltung", + "linked_orderposition": null +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium3.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium3.json new file mode 100644 index 00000000..320cda6c --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium3.json @@ -0,0 +1,20 @@ +{ + "id": 29, + "organizer": "mtrmt", + "created": "2026-05-08T10:01:01.156381Z", + "updated": "2026-05-08T10:09:02.941699Z", + "type": "barcode", + "identifier": "3333", + "claim_token": null, + "label": null, + "active": true, + "expires": null, + "customer": null, + "linked_orderpositions": [18697, 18699], + "linked_giftcard": null, + "info": { + + }, + "notes": "Zwei Tickets mit nicht überlappender Gültigkeit in gleicher Veranstaltung", + "linked_orderposition": null +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium4.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium4.json new file mode 100644 index 00000000..e595e8a8 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium4.json @@ -0,0 +1,20 @@ +{ + "id": 30, + "organizer": "mtrmt", + "created": "2026-05-08T10:01:13.202579Z", + "updated": "2026-05-08T10:09:42.379476Z", + "type": "barcode", + "identifier": "4444", + "claim_token": null, + "label": null, + "active": true, + "expires": null, + "customer": null, + "linked_orderpositions": [18705, 18699], + "linked_giftcard": null, + "info": { + + }, + "notes": "Zwei Tickets mit nicht überlappender Gültigkeit in unterschiedlicher Veranstaltung", + "linked_orderposition": null +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium5.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium5.json new file mode 100644 index 00000000..25dbe301 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium5.json @@ -0,0 +1,20 @@ +{ + "id": 31, + "organizer": "mtrmt", + "created": "2026-05-12T09:20:38.023179Z", + "updated": "2026-05-12T09:20:38.023234Z", + "type": "barcode", + "identifier": "5555", + "claim_token": null, + "label": null, + "active": false, + "expires": null, + "customer": null, + "linked_orderpositions": [18700], + "linked_giftcard": null, + "info": { + + }, + "notes": "Medium nicht aktiv", + "linked_orderposition": 18700 +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium6.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium6.json new file mode 100644 index 00000000..adda8f2f --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium6.json @@ -0,0 +1,20 @@ +{ + "id": 32, + "organizer": "mtrmt", + "created": "2026-05-12T09:22:31.207209Z", + "updated": "2026-05-12T09:22:31.207247Z", + "type": "barcode", + "identifier": "6666", + "claim_token": null, + "label": null, + "active": true, + "expires": "2000-12-31T00:00:00Z", + "customer": null, + "linked_orderpositions": [18701], + "linked_giftcard": null, + "info": { + + }, + "notes": "Medium expired", + "linked_orderposition": 18701 +} \ No newline at end of file diff --git a/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium7.json b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium7.json new file mode 100644 index 00000000..6fe77915 --- /dev/null +++ b/libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium7.json @@ -0,0 +1,20 @@ +{ + "id": 33, + "organizer": "mtrmt", + "created": "2026-05-13T07:31:23.075971Z", + "updated": "2026-05-13T07:31:23.076011Z", + "type": "barcode", + "identifier": "7777", + "claim_token": null, + "label": null, + "active": true, + "expires": null, + "customer": null, + "linked_orderpositions": [18702, 18703], + "linked_giftcard": null, + "info": { + + }, + "notes": "Two tickets, non overlapping with a bit spacing in between, same event", + "linked_orderposition": null +} \ No newline at end of file