Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ suspend inline fun <reified Request, reified Response : Any> HttpClient.patch(
route: String,
body: Request,
queryParams: Map<String, Any> = mapOf(),
// See post: per-call error mapper inspected before the generic status handling.
noinline mapKnownError: (suspend (HttpResponse) -> DataError.Remote?)? = null,
crossinline builder: HttpRequestBuilder.() -> Unit = {},
): Result<Response, DataError.Remote> {
return safeCall {
return safeCall(mapKnownError = mapKnownError) {
patch {
url(routeForRequest(route))
queryParams.forEach { (key, value) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ sealed interface DataError : Error {
// 403 with body { "code": "CANNOT_REMOVE_GROUP_CREATOR" }: the group's creator can only
// leave voluntarily. Distinguished from FORBIDDEN, which means the caller is not a member.
CANNOT_REMOVE_GROUP_CREATOR,

// 400 with body { "code": "INVALID_RECURRING_RULE" }: the recurring template or schedule
// is not something the server will store. Covers both a malformed template and the cap on
// how many active schedules one member may own, which the server states under this code.
INVALID_RECURRING_RULE,

// 503 with body { "code": "RECURRING_ENTRIES_DISABLED" }: the feature is switched off in
// this environment. Distinguished from SERVICE_UNAVAILABLE, which means the server is
// struggling — here the request was fine and only a config change makes it work.
RECURRING_ENTRIES_DISABLED,
UNKNOWN,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<string name="error_message_send_failed">Nachricht konnte nicht gesendet werden</string>
<string name="error_turnstile_retry">Verifizierung fehlgeschlagen. Bitte versuche es erneut.</string>
<string name="error_upgrade_required">Diese TabMates-Version wird nicht mehr unterstützt. Bitte aktualisiere die App.</string>
<string name="error_invalid_recurring_rule">Diese Wiederholung kann nicht gespeichert werden. Prüfe Datum und Betrag, oder beende eine andere Wiederholung, wenn du viele hast.</string>
<string name="error_recurring_entries_disabled">Wiederholende Einträge sind derzeit deaktiviert. Bitte versuche es später erneut.</string>
<string name="error_cannot_remove_self">Du kannst dich nicht selbst entfernen. Verlasse die Gruppe stattdessen in den Einstellungen.</string>
<string name="error_cannot_remove_group_creator">Die Person, die diese Gruppe erstellt hat, kann nicht entfernt werden.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<string name="error_message_send_failed">Failed to send message</string>
<string name="error_turnstile_retry">Couldn't verify you're human. Please try again.</string>
<string name="error_upgrade_required">This version of TabMates is no longer supported. Please update to continue.</string>
<string name="error_invalid_recurring_rule">That repeat schedule can't be saved. Check the dates and amounts, or end another schedule if you have a lot of them.</string>
<string name="error_recurring_entries_disabled">Repeating entries are switched off right now. Try again later.</string>
<string name="error_cannot_remove_self">You cannot remove yourself. Leave the group from its settings instead.</string>
<string name="error_cannot_remove_group_creator">The person who created this group cannot be removed.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import tabmatesapp.core.presentation.generated.resources.error_cannot_remove_sel
import tabmatesapp.core.presentation.generated.resources.error_conflict
import tabmatesapp.core.presentation.generated.resources.error_disk_full
import tabmatesapp.core.presentation.generated.resources.error_forbidden
import tabmatesapp.core.presentation.generated.resources.error_invalid_recurring_rule
import tabmatesapp.core.presentation.generated.resources.error_message_send_failed
import tabmatesapp.core.presentation.generated.resources.error_no_internet
import tabmatesapp.core.presentation.generated.resources.error_not_found
import tabmatesapp.core.presentation.generated.resources.error_payload_too_large
import tabmatesapp.core.presentation.generated.resources.error_recurring_entries_disabled
import tabmatesapp.core.presentation.generated.resources.error_request_timeout
import tabmatesapp.core.presentation.generated.resources.error_serialization
import tabmatesapp.core.presentation.generated.resources.error_server
Expand Down Expand Up @@ -44,6 +46,8 @@ fun DataError.toUiText(): UiText {
DataError.Remote.UPGRADE_REQUIRED -> Res.string.error_upgrade_required
DataError.Remote.CANNOT_REMOVE_SELF -> Res.string.error_cannot_remove_self
DataError.Remote.CANNOT_REMOVE_GROUP_CREATOR -> Res.string.error_cannot_remove_group_creator
DataError.Remote.INVALID_RECURRING_RULE -> Res.string.error_invalid_recurring_rule
DataError.Remote.RECURRING_ENTRIES_DISABLED -> Res.string.error_recurring_entries_disabled
DataError.Remote.UNKNOWN -> Res.string.error_unknown
DataError.Connection.NOT_CONNECTED -> Res.string.error_no_internet
DataError.Connection.MESSAGE_SEND_FAILED -> Res.string.error_message_send_failed
Expand Down
1 change: 1 addition & 0 deletions features/tabgroup/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ kotlin {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
implementation(libs.turbine)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import kotlin.time.Clock

@Module
@Configuration
Expand All @@ -14,4 +15,9 @@ class TabgroupDataModule {
// UpgradeRequiredNotifier, which CoreDataModule provides for the same reason.
@Single
fun provideGroupRemovalNotifier(): GroupRemovalNotifier = GroupRemovalNotifier()

// Bound rather than read statically so anything whose behaviour turns on the date — the
// scheduled ledger's day boundary above all — can be tested without waiting for midnight.
@Single
fun provideClock(): Clock = Clock.System
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package de.tabmates.features.tabgroup.data.dto

import de.tabmates.features.tabgroup.data.network.dto.WsSplitDto
import kotlinx.datetime.LocalDate
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator
import kotlin.time.Instant

/** A recurring schedule as the server reports it: identity, state, and its current template. */
@Serializable
data class RecurringSeriesDto(
val id: String,
val groupId: String,
val entryType: RecurringEntryTypeDto,
val isActive: Boolean,
/**
* The template names somebody who has left the group, so nothing is being generated until a
* member repairs it. The only state that needs a human — surface it.
*/
val needsAttention: Boolean,
val createdAt: Instant,
val createdBy: GroupParticipantDto,
val updatedAt: Instant,
val rule: RecurringRuleDto,
/**
* Future occurrences a member skipped. Defaulted so a server predating the field still parses —
* at the cost, until it ships, of a skipped date rendering as a placeholder that never resolves.
*/
val skippedOccurrenceDates: List<LocalDate> = emptyList(),
)

@Serializable
data class RecurringRuleDto(
val id: String,
val title: String,
val description: String,
val amount: Double,
val currency: String,
val exchangeRate: Double? = null,
val paidBy: GroupParticipantDto,
/** Set for `SETTLEMENT` schedules only. */
val receivedBy: GroupParticipantDto? = null,
/** Empty for `SETTLEMENT` schedules. */
val splits: List<RecurringTemplateSplitDto> = emptyList(),
val frequency: RecurrenceFrequencyDto,
val interval: Int,
val startDate: LocalDate,
val end: RecurringEndDto,
)

@Serializable
data class RecurringTemplateSplitDto(
val id: String? = null,
val participantId: String,
/**
* Null when the participant is not one the payload otherwise names — a template can outlive the
* membership of the people in it. [participantId] is always present.
*/
val participant: GroupParticipantDto? = null,
val split: WsSplitDto,
val resolvedAmount: Double,
)

@Serializable
enum class RecurringEntryTypeDto {
EXPENSE,
INCOME,
SETTLEMENT,
}

@Serializable
enum class RecurrenceFrequencyDto {
DAILY,
WEEKLY,
MONTHLY,
YEARLY,
}

/**
* How a schedule stops. A sealed shape on the wire, discriminated by `type` — the server writes
* `{"type":"NEVER"}`, `{"type":"UNTIL","date":...}`, `{"type":"COUNT","count":...}`.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("type")
sealed class RecurringEndDto {
@Serializable
@SerialName("NEVER")
data object Never : RecurringEndDto()

/** Inclusive: an occurrence landing exactly on [date] is still produced. */
@Serializable
@SerialName("UNTIL")
data class Until(
val date: LocalDate,
) : RecurringEndDto()

/** Total occurrences, counting ones a skip left empty. */
@Serializable
@SerialName("COUNT")
data class Count(
val count: Int,
) : RecurringEndDto()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package de.tabmates.features.tabgroup.data.dto

import de.tabmates.features.tabgroup.data.network.dto.WsSplitDto
import kotlinx.datetime.LocalDate
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

/**
* The template half of a create or edit.
*
* Mirrors the outgoing entry payload's shape and discriminator deliberately: this is the same entry
* the client would otherwise create by hand, plus when to repeat it.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("entryType")
sealed class RecurringTemplateDto {
abstract val paidByUserId: String
abstract val title: String
abstract val description: String
abstract val amount: Double
abstract val currency: String

/** Fallback rate only; the server resolves each occurrence's own rate when it writes one. */
abstract val exchangeRate: Double?
abstract val frequency: RecurrenceFrequencyDto
abstract val interval: Int
abstract val startDate: LocalDate
abstract val end: RecurringEndDto

@Serializable
@SerialName("EXPENSE")
data class Expense(
override val paidByUserId: String,
override val title: String,
override val description: String,
override val amount: Double,
override val currency: String,
override val exchangeRate: Double? = null,
override val frequency: RecurrenceFrequencyDto,
override val interval: Int = 1,
override val startDate: LocalDate,
override val end: RecurringEndDto = RecurringEndDto.Never,
val splits: List<NewRecurringTemplateSplitDto>,
) : RecurringTemplateDto()

@Serializable
@SerialName("INCOME")
data class Income(
override val paidByUserId: String,
override val title: String,
override val description: String,
override val amount: Double,
override val currency: String,
override val exchangeRate: Double? = null,
override val frequency: RecurrenceFrequencyDto,
override val interval: Int = 1,
override val startDate: LocalDate,
override val end: RecurringEndDto = RecurringEndDto.Never,
val splits: List<NewRecurringTemplateSplitDto>,
) : RecurringTemplateDto()

@Serializable
@SerialName("SETTLEMENT")
data class Settlement(
override val paidByUserId: String,
override val title: String,
override val description: String,
override val amount: Double,
override val currency: String,
override val exchangeRate: Double? = null,
override val frequency: RecurrenceFrequencyDto,
override val interval: Int = 1,
override val startDate: LocalDate,
override val end: RecurringEndDto = RecurringEndDto.Never,
val receivedByUserId: String,
) : RecurringTemplateDto()
}

@Serializable
data class NewRecurringTemplateSplitDto(
val participantId: String,
val split: WsSplitDto,
val resolvedAmount: Double,
)

@Serializable
data class CreateRecurringSeriesRequestDto(
val groupId: String,
/** Client-generated, so a create retried after a dropped response cannot make a second series. */
val id: String,
val template: RecurringTemplateDto,
)

/**
* Applies a new template from [effectiveFrom] onwards.
*
* [effectiveFrom] must be a future date the current schedule actually produces, and must equal the
* new template's `startDate` — otherwise the server rejects it rather than silently re-anchoring
* the rhythm to whichever day the edit happened to be made.
*/
@Serializable
data class UpdateRecurringSeriesRequestDto(
val effectiveFrom: LocalDate,
val template: RecurringTemplateDto,
)

@Serializable
data class SkipRecurringOccurrenceRequestDto(
val occurrenceDate: LocalDate,
)

/** Error bodies from the schedule endpoints, which state their refusals by code. */
@Serializable
internal data class RecurringSeriesErrorDto(
val code: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ data class SyncResponseDto(
val groups: List<GroupDto>,
val activeGroupIds: List<String>,
val tabEntries: List<TabEntryDto>,
/**
* Schedules created or changed since the cursor, active and ended alike. The entries they
* produce need no separate treatment — they are ordinary rows in [tabEntries]. Defaulted so a
* server predating the feature still parses.
*/
val recurringSeries: List<RecurringSeriesDto> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ sealed class TabEntryDto {
abstract val deletedAt: Instant?
abstract val deletedBy: GroupParticipantDto?

/**
* The recurring series that produced this entry, and the slot it filled. Both null for a
* hand-created entry, both set for a generated one; defaulted so a server predating the feature
* still parses.
*
* The slot is [recurringOccurrenceDate], not [entryDate] — the latter stays editable once the
* entry exists, and the slot must not move with it.
*/
abstract val recurringSeriesId: String?
abstract val recurringOccurrenceDate: LocalDate?

@Serializable
@SerialName("EXPENSE")
data class Expense(
Expand All @@ -54,6 +65,8 @@ sealed class TabEntryDto {
override val version: Int,
override val deletedAt: Instant?,
override val deletedBy: GroupParticipantDto?,
override val recurringSeriesId: String? = null,
override val recurringOccurrenceDate: LocalDate? = null,
) : TabEntryDto()

@Serializable
Expand All @@ -76,6 +89,8 @@ sealed class TabEntryDto {
override val version: Int,
override val deletedAt: Instant?,
override val deletedBy: GroupParticipantDto?,
override val recurringSeriesId: String? = null,
override val recurringOccurrenceDate: LocalDate? = null,
) : TabEntryDto()

@Serializable
Expand All @@ -98,5 +113,7 @@ sealed class TabEntryDto {
override val version: Int,
override val deletedAt: Instant?,
override val deletedBy: GroupParticipantDto?,
override val recurringSeriesId: String? = null,
override val recurringOccurrenceDate: LocalDate? = null,
) : TabEntryDto()
}
Loading