From 4f7a2f7ca2d1f55398ba382516c6237b43d65899 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 5 May 2026 23:22:16 +0200 Subject: [PATCH 01/11] Reorder AsyncCheckProvider --- .../libpretixsync/check/AsyncCheckProvider.kt | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) 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..9594e350 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -554,7 +554,29 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa 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) + } 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 { val medium = db.reusableMediumQueries.selectForCheck( identifier = ticketid_cleaned, type = source_type, @@ -585,28 +607,7 @@ 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 { From 555a0eed7bc0d4e694ff403fddc6a7e7bcd081a8 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Thu, 7 May 2026 13:21:54 +0200 Subject: [PATCH 02/11] Draft: support multiple tickets/orderpositions as candidates for the checkin --- .../libpretixsync/check/AsyncCheckProvider.kt | 145 ++++++++++++++---- 1 file changed, 118 insertions(+), 27 deletions(-) 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 9594e350..340b09b8 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -36,6 +36,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() @@ -577,12 +578,16 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ) 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) { + // 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(), @@ -610,12 +615,119 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa } } - private fun checkOfflineWithData(eventsAndCheckinLists: Map, secret: String, tickets: List, answers: List?, ignore_unpaid: Boolean, type: TicketCheckProvider.CheckInType, nonce: String?, allowQuestions: Boolean): TicketCheckProvider.CheckResult { + private fun filterPositions(eventsAndCheckinLists: Map, positions: List): List { + val 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(Pair(position, "Event not found")) + return@forEach + } + + val listId = eventsAndCheckinLists[eventSlug] + if (listId == null) { + errors.add(Pair(position, "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(Pair(position, "Check-in list not found")) + return@forEach + } + + // server side: 3a. + val 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(position) + + val orderPositions = db.orderPositionQueries.selectForOrder(order.id).executeAsList() + .map { it.toModel() } + candidates.addAll(orderPositions.filter { + it.addonToServerId == position.serverId + }) + // server side: 3b. + 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) + } + if (candidates.isEmpty()) { + errors.add(Pair(position, "PRODUCT")) + } else { + resultingPositions.addAll(candidates) + } + } else { + // This is a useless configuration that the backend won't allow, but we'll still handle + // it here for completeness + resultingPositions.addAll(candidates) + } + } + + // 3c. + val nowOdt = javaTimeNow() + resultingPositions.filter { op -> + (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) + } + + results.addAll(resultingPositions) + } + + if (errors.isNotEmpty()) { + // FIXME + } + + return results + } + + private fun checkOfflineWithData( + eventsAndCheckinLists: Map, + secret: String, + tickets: List, + answers: List?, + ignore_unpaid: Boolean, + type: TicketCheckProvider.CheckInType, + nonce: String?, + allowQuestions: 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 = filterPositions(eventsAndCheckinLists, tickets) + if (positions.isEmpty()) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.ERROR, + "Event not 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() @@ -628,31 +740,9 @@ 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 - } + // FIXME: we need to somehow still migrate these failed checkins 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) @@ -664,6 +754,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa } else { tickets[0] } + */ val positionItem = if (position.id == tickets[0].id) { item From 1984b75446c5903dec03b6ee11d2c17a77296979 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Thu, 7 May 2026 19:41:16 +0200 Subject: [PATCH 03/11] Draft: error handling for multiple tickets/orderpositions as candidates --- .../libpretixsync/check/AsyncCheckProvider.kt | 84 ++++++++++--------- 1 file changed, 46 insertions(+), 38 deletions(-) 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 340b09b8..fbe79db9 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -11,6 +11,7 @@ import eu.pretix.libpretixsync.db.Answer import eu.pretix.libpretixsync.db.NonceGenerator import eu.pretix.libpretixsync.db.QuestionLike 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 @@ -615,22 +616,30 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa } } - private fun filterPositions(eventsAndCheckinLists: Map, positions: List): List { + data class PositionFilteringError( + val position: OrderPositionModel, + val eventSlug: String, + val list: CheckInList?, + val error: TicketCheckProvider.CheckResult.Type, + val message: String + ) + + private fun filterPositions(eventsAndCheckinLists: Map, positions: List): Pair, List> { val results = mutableListOf() - val errors = 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(Pair(position, "Event not found")) + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Event not found")) return@forEach } val listId = eventsAndCheckinLists[eventSlug] if (listId == null) { - errors.add(Pair(position, "No check-in list selected")) + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "No check-in list selected")) return@forEach } @@ -640,12 +649,12 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ).executeAsOneOrNull()?.toModel() if (list == null) { - errors.add(Pair(position, "Check-in list not found")) + errors.add(PositionFilteringError(position, eventSlug, null, TicketCheckProvider.CheckResult.Type.ERROR, "Check-in list not found")) return@forEach } // server side: 3a. - val resultingPositions = mutableSetOf(position) + 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(position) @@ -656,7 +665,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa it.addonToServerId == position.serverId }) // server side: 3b. - if (!list.allItems) { + val filteredCandidates = if (!list.allItems) { val items = db.checkInListQueries.selectItemIdsForList(list.id) .executeAsList() .map { @@ -669,32 +678,31 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa db.itemQueries.selectById(candidate.itemId).executeAsOne() items.contains(candidateItem.id) } - if (candidates.isEmpty()) { - errors.add(Pair(position, "PRODUCT")) - } else { - resultingPositions.addAll(candidates) - } } else { // This is a useless configuration that the backend won't allow, but we'll still handle // it here for completeness - resultingPositions.addAll(candidates) + candidates + } + + if (filteredCandidates.isEmpty()) { + errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.PRODUCT, "PRODUCT")) + } else if (filteredCandidates.size > 1) { + errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.AMBIGUOUS, "AMBIGUOUS")) + } else { + resultingPositions.add(filteredCandidates[0]) } } // 3c. val nowOdt = javaTimeNow() - resultingPositions.filter { op -> + resultingPositions = resultingPositions.filter { op -> (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) - } + }.toMutableSet() results.addAll(resultingPositions) } - if (errors.isNotEmpty()) { - // FIXME - } - - return results + return Pair(results, errors) } private fun checkOfflineWithData( @@ -710,11 +718,26 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa // !!! When extending this, also extend checkOfflineWithoutData !!! val dt = now() - val positions = filterPositions(eventsAndCheckinLists, tickets) + val (positions, err) = filterPositions(eventsAndCheckinLists, tickets) + if (err.isNotEmpty()) { + val firstError = err.first() + val item = db.itemQueries.selectById(firstError.position.itemId).executeAsOne().toModel() + if (firstError.error == TicketCheckProvider.CheckResult.Type.PRODUCT && firstError.list != null) { + 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) + } + if (firstError.error == TicketCheckProvider.CheckResult.Type.AMBIGUOUS && firstError.list != null) { + 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) + } + return TicketCheckProvider.CheckResult( + firstError.error, + firstError.message, + offline = true + ) + } if (positions.isEmpty()) { return TicketCheckProvider.CheckResult( TicketCheckProvider.CheckResult.Type.ERROR, - "Event not found", + "No matching ticket found", offline = true ) } @@ -724,6 +747,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa offline = true ) } + val position = positions[0] val order = db.orderQueries.selectById(position.orderId).executeAsOne().toModel() @@ -740,22 +764,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) { - // FIXME: we need to somehow still migrate these failed checkins - 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 { From b28a6563e4656299b9964e39aaa3269a465767f3 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Mon, 11 May 2026 14:12:28 +0200 Subject: [PATCH 04/11] Draft: tests for reusable medium offline checkin --- .../libpretixsync/check/AsyncCheckProvider.kt | 21 +- .../sync/ReusableMediaSyncAdapter.kt | 19 + .../AsyncCheckProviderReusableMediumTest.kt | 71 +++ .../scanproxy/tests/test/FakeConfigStore.kt | 8 +- .../scanproxy/tests/test/FakePretixApi.kt | 2 +- .../checkinlists/rmevent1-list1.json | 17 + .../resources/events/rmevent1.json | 38 ++ .../resources/events/rmevent2.json | 38 ++ .../resources/items/rmevent1-item1.json | 70 +++ .../resources/items/rmevent2-item1.json | 70 +++ .../resources/orders/rmevent1-order1.json | 410 ++++++++++++++++++ .../resources/orders/rmevent2-order1.json | 410 ++++++++++++++++++ .../reusablemedia/mtrmt-medium1.json | 20 + .../reusablemedia/mtrmt-medium2.json | 20 + .../reusablemedia/mtrmt-medium3.json | 20 + .../reusablemedia/mtrmt-medium4.json | 20 + 16 files changed, 1240 insertions(+), 14 deletions(-) create mode 100644 libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt create mode 100644 libpretixsync/src/testFixtures/resources/checkinlists/rmevent1-list1.json create mode 100644 libpretixsync/src/testFixtures/resources/events/rmevent1.json create mode 100644 libpretixsync/src/testFixtures/resources/events/rmevent2.json create mode 100644 libpretixsync/src/testFixtures/resources/items/rmevent1-item1.json create mode 100644 libpretixsync/src/testFixtures/resources/items/rmevent2-item1.json create mode 100644 libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json create mode 100644 libpretixsync/src/testFixtures/resources/orders/rmevent2-order1.json create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium1.json create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium2.json create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium3.json create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium4.json 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 fbe79db9..4a50090d 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -89,6 +89,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 } @@ -621,7 +622,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa val eventSlug: String, val list: CheckInList?, val error: TicketCheckProvider.CheckResult.Type, - val message: String + val message: String? = null ) private fun filterPositions(eventsAndCheckinLists: Map, positions: List): Pair, List> { @@ -657,7 +658,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa 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(position) + val candidates = mutableListOf() val orderPositions = db.orderPositionQueries.selectForOrder(order.id).executeAsList() .map { it.toModel() } @@ -685,19 +686,21 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa } if (filteredCandidates.isEmpty()) { - errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.PRODUCT, "PRODUCT")) + 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, "AMBIGUOUS")) + errors.add(PositionFilteringError(position, eventSlug, list, TicketCheckProvider.CheckResult.Type.AMBIGUOUS)) } else { resultingPositions.add(filteredCandidates[0]) } } - // 3c. - val nowOdt = javaTimeNow() - resultingPositions = resultingPositions.filter { op -> - (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) - }.toMutableSet() + // server side: 3c. + if (resultingPositions.size > 1) { + val nowOdt = javaTimeNow() + resultingPositions = resultingPositions.filter { op -> + (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) + }.toMutableSet() + } results.addAll(resultingPositions) } 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..8b520e59 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,12 @@ 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.json.JSONException import org.json.JSONObject import java.io.UnsupportedEncodingException @@ -293,4 +295,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/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..4c8d7112 --- /dev/null +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -0,0 +1,71 @@ +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.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") + ) + 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")) + + 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")) + } + + @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.AMBIGUOUS, r.type) + } + + @Test + fun testTwoTicketsTimesNonOverlappingSameEvent() { + val r = p!!.check(mapOf("event1" to 35L), "3333") + assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + } + + @Test + fun testTwoTicketsTimesNonOverlappingDifferentEvents() { + val r = p!!.check(mapOf("event1" to 35L), "4444") + assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + } +} 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..a1e98b85 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() 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/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..afdd0248 --- /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": null, + "valid_until": null, + "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": null, + "valid_until": null, + "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 From 3e8632ed741bf39ff8a5c74f8261c55ef98468ff Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Mon, 11 May 2026 15:43:48 +0200 Subject: [PATCH 05/11] Fix sync of order positions linked to reusable medium --- .../sync/ReusableMediaSyncAdapter.kt | 6 +++--- .../sqldelight/ReusableMedium.sq | 2 +- .../AsyncCheckProviderReusableMediumTest.kt | 21 ++++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) 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 8b520e59..edd0276c 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt @@ -119,9 +119,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() } 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..b4d9c974 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,7 +13,7 @@ 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 OrderPosition ON ReusableMedium_OrderPosition.OrderPositionId = OrderPosition.id diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt index 4c8d7112..0cac32ad 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -33,16 +33,17 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { CheckInListSyncAdapter(db, FakeFileStorage(), "event1", fakeApi!!, "", null, 0).standaloneRefreshFromJSON( jsonResource("checkinlists/rmevent1-list1.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")) 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")) } @Test @@ -54,7 +55,10 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { @Test fun testTwoTicketsTimesOverlappingDifferentEvents() { val r = p!!.check(mapOf("event1" to 35L), "2222") - assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + assertEquals(TicketCheckProvider.CheckResult.Type.VALID, r.type) + assertEquals("Regular ticket", r.ticket) + assertEquals("W0JKM", r.orderCode) + assertEquals(1L, r.positionId) } @Test @@ -66,6 +70,9 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { @Test fun testTwoTicketsTimesNonOverlappingDifferentEvents() { val r = p!!.check(mapOf("event1" to 35L), "4444") - assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID, r.type) + // assertEquals("Regular ticket", r.ticket) + // assertEquals("W0JKM", r.orderCode) + // assertEquals(3L, r.positionId) } } From 3935ba0de99e75745bf8f9a27d9d829460ec4ebf Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 12 May 2026 13:35:25 +0200 Subject: [PATCH 06/11] Fix matching of order position to reusable medium --- .../common/eu/pretix/libpretixsync/sqldelight/ReusableMedium.sq | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b4d9c974..ecb69cdd 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 @@ -15,7 +15,7 @@ WHERE server_id IN ?; selectForCheck: 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 From cbcbb86b1f75e42e043cfdbe3a7ae4c34ab82f87 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 12 May 2026 13:42:24 +0200 Subject: [PATCH 07/11] Reject resuable media that are not active --- .../libpretixsync/check/AsyncCheckProvider.kt | 8 ++++++++ .../AsyncCheckProviderReusableMediumTest.kt | 9 +++++++++ .../reusablemedia/mtrmt-medium5.json | 20 +++++++++++++++++++ .../reusablemedia/mtrmt-medium6.json | 20 +++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium5.json create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium6.json 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 4a50090d..d1112c28 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -587,6 +587,14 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa event_slugs = eventsAndCheckinLists.keys.toList(), ).executeAsOneOrNull()?.toModel() if (medium != null) { + if (!medium.active) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.INVALID, + "Medium not active", + 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. diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt index 0cac32ad..d1fc9c8a 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -10,6 +10,7 @@ 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 @@ -44,6 +45,14 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { 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")) + } + + @Test + fun testMediumNotActive() { + val r = p!!.check(mapOf("event1" to 35L), "5555") + assertEquals(TicketCheckProvider.CheckResult.Type.INVALID, r.type) } @Test 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 From 6446d261842863449f2d961eb74306b3039bfdee Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 12 May 2026 13:43:31 +0200 Subject: [PATCH 08/11] Map reusable medium expiry to OffsetDateTime --- .../libpretixsync/models/ReusableMedium.kt | 4 +++- ...bleMedium.kt => ReusableMediumExtensions.kt} | 6 +++++- .../sync/ReusableMediaSyncAdapter.kt | 17 +++++++++++++++-- .../libpretixsync/sqldelight/ReusableMedium.sq | 2 +- .../libpretixsync/sqldelight/ReusableMedium.sq | 3 ++- .../libpretixsync/db/BaseDatabaseTest.java | 4 ++++ 6 files changed, 30 insertions(+), 6 deletions(-) rename libpretixsync/src/main/java/eu/pretix/libpretixsync/models/db/{ReusableMedium.kt => ReusableMediumExtensions.kt} (71%) 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/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 edd0276c..05628a3b 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/sync/ReusableMediaSyncAdapter.kt @@ -11,6 +11,7 @@ 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 @@ -64,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"), @@ -90,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"), 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/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 From 033805c64242684296e5fa4c172501f260fbb321 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 12 May 2026 13:44:00 +0200 Subject: [PATCH 09/11] Reject reusable media that are expired --- .../eu/pretix/libpretixsync/check/AsyncCheckProvider.kt | 8 ++++++++ .../check/AsyncCheckProviderReusableMediumTest.kt | 7 +++++++ 2 files changed, 15 insertions(+) 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 d1112c28..31198253 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -595,6 +595,14 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ) } + if (medium.expires?.isBefore(javaTimeNow()) == true) { + return TicketCheckProvider.CheckResult( + TicketCheckProvider.CheckResult.Type.CANCELED, + "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. diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt index d1fc9c8a..4d8af7d7 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -55,6 +55,13 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { 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.CANCELED, r.type) + } + @Test fun testTwoTicketsTimesOverlappingSameEvent() { val r = p!!.check(mapOf("event1" to 35L), "1111") From 7bcb18170660a24032ea7a7dbbb4bd1b80e4e4ef Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Tue, 12 May 2026 15:18:52 +0200 Subject: [PATCH 10/11] Rework test for two tickets with non overlapping times in different events on the same reusable medium --- .../AsyncCheckProviderReusableMediumTest.kt | 21 ++++++++++++++----- .../checkinlists/rmevent2-list1.json | 17 +++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 libpretixsync/src/testFixtures/resources/checkinlists/rmevent2-list1.json diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt index 4d8af7d7..3a99672a 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -34,6 +34,9 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { 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")) @@ -85,10 +88,18 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { @Test fun testTwoTicketsTimesNonOverlappingDifferentEvents() { - val r = p!!.check(mapOf("event1" to 35L), "4444") - assertEquals(TicketCheckProvider.CheckResult.Type.INVALID, r.type) - // assertEquals("Regular ticket", r.ticket) - // assertEquals("W0JKM", r.orderCode) - // assertEquals(3L, r.positionId) + 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) } } 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 From 8d0b0dd6be162fce55917198ba6673e7ec33a595 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Wed, 13 May 2026 10:22:40 +0200 Subject: [PATCH 11/11] Offer closest valid ticket for the error message if none of the tickets on the reusable medium can get checked in now --- .../libpretixsync/check/AsyncCheckProvider.kt | 89 ++++++++++++++++--- .../AsyncCheckProviderReusableMediumTest.kt | 39 +++++++- .../resources/orders/rmevent1-order1.json | 8 +- .../reusablemedia/mtrmt-medium7.json | 20 +++++ 4 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 libpretixsync/src/testFixtures/resources/reusablemedia/mtrmt-medium7.json 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 31198253..c1439b11 100644 --- a/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt +++ b/libpretixsync/src/main/java/eu/pretix/libpretixsync/check/AsyncCheckProvider.kt @@ -642,7 +642,7 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa ) private fun filterPositions(eventsAndCheckinLists: Map, positions: List): Pair, List> { - val results = mutableListOf() + var results = mutableListOf() val errors = mutableListOf() positions.forEach { position -> val order = db.orderQueries.selectById(position.orderId).executeAsOne().toModel() @@ -710,15 +710,66 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa } } - // server side: 3c. - if (resultingPositions.size > 1) { - val nowOdt = javaTimeNow() - resultingPositions = resultingPositions.filter { op -> - (op.validFrom == null || op.validFrom < nowOdt) && (op.validUntil == null || op.validUntil > nowOdt) - }.toMutableSet() + 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 + } } - results.addAll(resultingPositions) + 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) @@ -740,18 +791,28 @@ class AsyncCheckProvider(private val config: ConfigStore, private val db: SyncDa 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.error == TicketCheckProvider.CheckResult.Type.PRODUCT && firstError.list != null) { - 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) - } - if (firstError.error == TicketCheckProvider.CheckResult.Type.AMBIGUOUS && firstError.list != null) { - 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) + 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( diff --git a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt index 3a99672a..efd97508 100644 --- a/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt +++ b/libpretixsync/src/test/java/eu/pretix/libpretixsync/check/AsyncCheckProviderReusableMediumTest.kt @@ -50,6 +50,7 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { 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 @@ -82,8 +83,17 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { @Test fun testTwoTicketsTimesNonOverlappingSameEvent() { - val r = p!!.check(mapOf("event1" to 35L), "3333") - assertEquals(TicketCheckProvider.CheckResult.Type.AMBIGUOUS, r.type) + 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 @@ -102,4 +112,29 @@ class AsyncCheckProviderReusableMediumTest : BaseDatabaseTest() { 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/testFixtures/resources/orders/rmevent1-order1.json b/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json index afdd0248..a695bf72 100644 --- a/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json +++ b/libpretixsync/src/testFixtures/resources/orders/rmevent1-order1.json @@ -283,8 +283,8 @@ "seat": null, "canceled": false, "tax_code": null, - "valid_from": null, - "valid_until": null, + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2026-05-01T23:59:59Z", "blocked": null, "voucher_budget_use": null, "plugin_data": { @@ -325,8 +325,8 @@ "seat": null, "canceled": false, "tax_code": null, - "valid_from": null, - "valid_until": null, + "valid_from": "2026-09-01T00:00:00Z", + "valid_until": "2026-12-31T23:59:59Z", "blocked": null, "voucher_budget_use": null, "plugin_data": { 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