Skip to main content

Subscription Service — Data Layer

The subscription service manages Stripe-based billing: checking the current subscription status, creating checkout sessions, and opening the billing portal. It follows the standard data layer pattern with one exception — the repository does inline DTO→domain mapping instead of delegating to SubscriptionMapper.

SubscriptionApi

Retrofit interface at data/remote/api/SubscriptionApi.kt. Three endpoints under api/billing/.

interface SubscriptionApi {

@GET("api/billing/status")
suspend fun getStatus(): SubscriptionDto

@POST("api/billing/checkout")
suspend fun createCheckoutSession(@Body request: CheckoutRequest): CheckoutSessionDto

@GET("api/billing/portal")
suspend fun getPortalUrl(): BillingPortalDto
}
MethodHTTPPathParametersReturns
getStatus()GETapi/billing/statusSubscriptionDto
createCheckoutSession(request)POSTapi/billing/checkoutCheckoutRequest body (planKey: String)CheckoutSessionDto
getPortalUrl()GETapi/billing/portalBillingPortalDto

SubscriptionRepository

Domain interface at domain/repositories/SubscriptionRepository.kt. Exposes subscription operations using domain types.

interface SubscriptionRepository {
suspend fun getStatus(): Subscription
suspend fun createCheckoutSession(planKey: String): CheckoutSession
suspend fun getPortalUrl(): BillingPortal
}
MethodParametersReturnsDescription
getStatusSubscriptionCurrent subscription status, plan, limits
createCheckoutSessionplanKey: StringCheckoutSessionCreates a Stripe checkout session
getPortalUrlBillingPortalURL to the Stripe billing portal

SubscriptionRepositoryImpl

Implementation at data/repositories/SubscriptionRepositoryImpl.kt. Annotated @Singleton and injected via Hilt.

Constructor

@Singleton
class SubscriptionRepositoryImpl @Inject constructor(
private val subscriptionApi: SubscriptionApi
) : SubscriptionRepository

Note: Unlike most repositories, this implementation does not inject a mapper. DTO→domain conversion is performed inline within each method.

Method implementations

getStatus — Fetches the subscription DTO and maps it inline. Parses status string via SubscriptionStatus.fromString() and timestamps via Instant.parse() with a fallback to Instant.now().

override suspend fun getStatus(): Subscription = try {
val response = subscriptionApi.getStatus()
Subscription(
status = SubscriptionStatus.fromString(response.status),
planKey = response.planKey,
currentPeriodEnd = response.currentPeriodEnd?.let { parseInstant(it) },
cancelAtPeriodEnd = response.cancelAtPeriodEnd,
trialEnd = response.trialEnd?.let { parseInstant(it) },
deviceLimit = response.deviceLimit,
devicesUsed = response.devicesUsed
)
} catch (e: HttpException) { throw mapHttpException(e) }
catch (e: IOException) { throw NetworkException("Network error fetching subscription status", e) }

createCheckoutSession — Wraps planKey in a CheckoutRequest and maps the response inline.

getPortalUrl — Fetches the portal URL and wraps it in a BillingPortal domain entity.

Subscription DTOs

Located at data/remote/dto/SubscriptionDtos.kt.

SubscriptionDto

data class SubscriptionDto(
val status: String,
val planKey: String? = null,
@SerializedName("current_period_end") val currentPeriodEnd: String? = null,
@SerializedName("cancel_at_period_end") val cancelAtPeriodEnd: Boolean = false,
@SerializedName("trial_end") val trialEnd: String? = null,
@SerializedName("device_limit") val deviceLimit: Int = 1,
@SerializedName("devices_used") val devicesUsed: Int = 0
)

CheckoutRequest / CheckoutSessionDto / BillingPortalDto

data class CheckoutRequest(val planKey: String)
data class CheckoutSessionDto(val sessionId: String, val url: String)
data class BillingPortalDto(val url: String)

Subscription (Domain Entity)

Located at domain/entities/Subscription.kt.

data class Subscription(
val status: SubscriptionStatus,
val planKey: String?,
val currentPeriodEnd: Instant?,
val cancelAtPeriodEnd: Boolean,
val trialEnd: Instant?,
val deviceLimit: Int,
val devicesUsed: Int
) {
val isActive: Boolean
get() = status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.TRIALING
val canAddDevice: Boolean
get() = devicesUsed < deviceLimit
}

DTO → Domain field mapping

DTO field (SubscriptionDto)Domain field (Subscription)Transformation
status: Stringstatus: SubscriptionStatusSubscriptionStatus.fromString() enum parsing
currentPeriodEnd: String?currentPeriodEnd: Instant?Instant.parse() with fallback to Instant.now()
trialEnd: String?trialEnd: Instant?Instant.parse() with fallback to Instant.now()
All other fieldsSame name and typeDirect copy

SubscriptionStatus (enum)

Defined alongside Subscription in domain/entities/Subscription.kt. Parsed from the API string via fromString().

enum class SubscriptionStatus {
ACTIVE, TRIALING, PAST_DUE, CANCELED, INCOMPLETE, INACTIVE;

companion object {
fun fromString(value: String?): SubscriptionStatus = when (value?.lowercase()) {
"active" -> ACTIVE
"trialing" -> TRIALING
"past_due" -> PAST_DUE
"canceled" -> CANCELED
"incomplete" -> INCOMPLETE
else -> INACTIVE
}
}
}

Any unrecognised or null status string maps to INACTIVE.

CheckoutSession / BillingPortal (Domain Entities)

Also defined in domain/entities/Subscription.kt.

data class CheckoutSession(val sessionId: String, val url: String)
data class BillingPortal(val url: String)

Use Cases

Three use cases in domain/usecases/billing/BillingUseCases.kt, all constructor-injected with SubscriptionRepository.

Use CaseParametersReturnsNotes
GetSubscriptionStatusUseCaseSubscriptionDelegates directly to getStatus()
CreateCheckoutSessionUseCaseplanKey: StringCheckoutSessionValidates planKey is not blank (throws ValidationException), trims before passing
GetBillingPortalUseCaseBillingPortalDelegates directly to getPortalUrl()

Dependency Injection

SubscriptionRepositoryImpl is bound to the SubscriptionRepository interface in di/RepositoryModule.kt via Hilt @Binds:

@Binds @Singleton
abstract fun bindSubscriptionRepository(impl: SubscriptionRepositoryImpl): SubscriptionRepository

The use cases use standard @Inject constructor injection and do not require a dedicated module.

SubscriptionMapper (unused)

A SubscriptionMapper class exists at data/mappers/SubscriptionMapper.kt with similar (but not identical) mapping logic. It is not currently injected or used by SubscriptionRepositoryImpl — the repository performs its own inline mapping instead.

Key difference: The mapper's parseInstant returns null on parse failure, while the repository's parseInstant falls back to Instant.now(). This means the mapper would silently drop unparseable timestamps, whereas the repository substitutes the current time.