Skip to content

Commit 34e7057

Browse files
committed
**feat(billing): Add patron subscription management with schema and services**
- Introduced `billing` schema and `patron_subscription` table to support patronage tiers, subscription statuses, and payment integration. - Added `PatronageService` to handle subscription creation, cancellation, renewal, and webhook events for payment providers. - Implemented repository (`PatronSubscriptionRepository`) and domain models for subscription management. - Developed comprehensive unit tests (`PatronageServiceSpec`) for key service functionalities and edge cases. - Integrated repository binding and schema registration in the application module.
1 parent 6875d13 commit 34e7057

9 files changed

Lines changed: 696 additions & 0 deletions

File tree

app/models/dal/DatabaseSchema.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,9 @@ object DatabaseSchema {
197197
import models.dal.curator.*
198198
val auditLog = TableQuery[AuditLogTable]
199199
}
200+
201+
object billing {
202+
import models.dal.domain.billing.*
203+
val patronSubscriptions = TableQuery[PatronSubscriptionTable]
204+
}
200205
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package models.dal.domain.billing
2+
3+
import models.dal.MyPostgresProfile.api.*
4+
import models.domain.billing.PatronSubscription
5+
import slick.lifted.{ProvenShape, Tag}
6+
7+
import java.time.LocalDateTime
8+
import java.util.UUID
9+
10+
class PatronSubscriptionTable(tag: Tag) extends Table[PatronSubscription](tag, Some("billing"), "patron_subscription") {
11+
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
12+
def userId = column[UUID]("user_id")
13+
def patronTier = column[String]("patron_tier")
14+
def status = column[String]("status")
15+
def paymentProvider = column[String]("payment_provider")
16+
def providerSubscriptionId = column[Option[String]]("provider_subscription_id")
17+
def providerCustomerId = column[Option[String]]("provider_customer_id")
18+
def amountCents = column[Int]("amount_cents")
19+
def currency = column[String]("currency")
20+
def billingInterval = column[String]("billing_interval")
21+
def currentPeriodStart = column[Option[LocalDateTime]]("current_period_start")
22+
def currentPeriodEnd = column[Option[LocalDateTime]]("current_period_end")
23+
def cancelledAt = column[Option[LocalDateTime]]("cancelled_at")
24+
def createdAt = column[LocalDateTime]("created_at")
25+
def updatedAt = column[LocalDateTime]("updated_at")
26+
27+
override def * : ProvenShape[PatronSubscription] = (
28+
id.?,
29+
userId,
30+
patronTier,
31+
status,
32+
paymentProvider,
33+
providerSubscriptionId,
34+
providerCustomerId,
35+
amountCents,
36+
currency,
37+
billingInterval,
38+
currentPeriodStart,
39+
currentPeriodEnd,
40+
cancelledAt,
41+
createdAt,
42+
updatedAt
43+
).mapTo[PatronSubscription]
44+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package models.domain.billing
2+
3+
import play.api.libs.json.{Json, OFormat}
4+
5+
import java.time.LocalDateTime
6+
import java.util.UUID
7+
8+
case class PatronSubscription(
9+
id: Option[Int] = None,
10+
userId: UUID,
11+
patronTier: String,
12+
status: String = "ACTIVE",
13+
paymentProvider: String,
14+
providerSubscriptionId: Option[String] = None,
15+
providerCustomerId: Option[String] = None,
16+
amountCents: Int,
17+
currency: String = "USD",
18+
billingInterval: String,
19+
currentPeriodStart: Option[LocalDateTime] = None,
20+
currentPeriodEnd: Option[LocalDateTime] = None,
21+
cancelledAt: Option[LocalDateTime] = None,
22+
createdAt: LocalDateTime = LocalDateTime.now(),
23+
updatedAt: LocalDateTime = LocalDateTime.now()
24+
)
25+
26+
object PatronSubscription {
27+
implicit val format: OFormat[PatronSubscription] = Json.format[PatronSubscription]
28+
29+
val ValidTiers: Set[String] = Set("SUPPORTER", "CONTRIBUTOR", "SUSTAINER", "FOUNDING_PATRON")
30+
val ValidStatuses: Set[String] = Set("ACTIVE", "CANCELLED", "PAST_DUE", "EXPIRED")
31+
val ValidProviders: Set[String] = Set("STRIPE", "PAYPAL")
32+
val ValidIntervals: Set[String] = Set("MONTHLY", "YEARLY")
33+
}
34+
35+
object PatronTier {
36+
val Supporter = "SUPPORTER"
37+
val Contributor = "CONTRIBUTOR"
38+
val Sustainer = "SUSTAINER"
39+
val FoundingPatron = "FOUNDING_PATRON"
40+
41+
def amountCents(tier: String, interval: String): Int = (tier, interval) match {
42+
case (Supporter, "MONTHLY") => 200
43+
case (Supporter, "YEARLY") => 2000
44+
case (Contributor, "MONTHLY") => 500
45+
case (Contributor, "YEARLY") => 5000
46+
case (Sustainer, "MONTHLY") => 1000
47+
case (Sustainer, "YEARLY") => 10000
48+
case (FoundingPatron, "MONTHLY") => 5000
49+
case (FoundingPatron, "YEARLY") => 50000
50+
case _ => throw new IllegalArgumentException(s"Unknown tier/interval: $tier/$interval")
51+
}
52+
53+
def displayName(tier: String): String = tier match {
54+
case Supporter => "Supporter"
55+
case Contributor => "Contributor"
56+
case Sustainer => "Sustainer"
57+
case FoundingPatron => "Founding Patron"
58+
case other => other
59+
}
60+
}
61+
62+
case class PatronSummary(
63+
activePatrons: Int,
64+
tierCounts: Map[String, Int],
65+
monthlyRevenueCents: Int
66+
)
67+
68+
object PatronSummary {
69+
implicit val format: OFormat[PatronSummary] = Json.format[PatronSummary]
70+
}

app/modules/BaseModule.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,5 +195,10 @@ class BaseModule extends AbstractModule {
195195
bind(classOf[PdsSubmissionRepository])
196196
.to(classOf[PdsSubmissionRepositoryImpl])
197197
.asEagerSingleton()
198+
199+
// Billing / Patronage
200+
bind(classOf[PatronSubscriptionRepository])
201+
.to(classOf[PatronSubscriptionRepositoryImpl])
202+
.asEagerSingleton()
198203
}
199204
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package repositories
2+
3+
import jakarta.inject.Inject
4+
import models.dal.DatabaseSchema
5+
import models.domain.billing.PatronSubscription
6+
import play.api.Logging
7+
import play.api.db.slick.DatabaseConfigProvider
8+
9+
import java.time.LocalDateTime
10+
import java.util.UUID
11+
import scala.concurrent.{ExecutionContext, Future}
12+
13+
trait PatronSubscriptionRepository {
14+
def create(subscription: PatronSubscription): Future[PatronSubscription]
15+
def findById(id: Int): Future[Option[PatronSubscription]]
16+
def findByUserId(userId: UUID): Future[Seq[PatronSubscription]]
17+
def findActiveByUserId(userId: UUID): Future[Option[PatronSubscription]]
18+
def findByProviderSubscriptionId(provider: String, providerSubId: String): Future[Option[PatronSubscription]]
19+
def findByStatus(status: String): Future[Seq[PatronSubscription]]
20+
def updateStatus(id: Int, status: String): Future[Boolean]
21+
def updatePeriod(id: Int, periodStart: LocalDateTime, periodEnd: LocalDateTime): Future[Boolean]
22+
def cancel(id: Int): Future[Boolean]
23+
def countByTier(): Future[Map[String, Int]]
24+
def countActive(): Future[Int]
25+
}
26+
27+
class PatronSubscriptionRepositoryImpl @Inject()(
28+
dbConfigProvider: DatabaseConfigProvider
29+
)(implicit ec: ExecutionContext)
30+
extends BaseRepository(dbConfigProvider)
31+
with PatronSubscriptionRepository
32+
with Logging {
33+
34+
import models.dal.MyPostgresProfile.api.*
35+
36+
private val subscriptions = DatabaseSchema.billing.patronSubscriptions
37+
38+
override def create(subscription: PatronSubscription): Future[PatronSubscription] =
39+
runQuery(
40+
(subscriptions returning subscriptions.map(_.id) into ((s, id) => s.copy(id = Some(id)))) += subscription
41+
)
42+
43+
override def findById(id: Int): Future[Option[PatronSubscription]] =
44+
runQuery(subscriptions.filter(_.id === id).result.headOption)
45+
46+
override def findByUserId(userId: UUID): Future[Seq[PatronSubscription]] =
47+
runQuery(subscriptions.filter(_.userId === userId).sortBy(_.createdAt.desc).result)
48+
49+
override def findActiveByUserId(userId: UUID): Future[Option[PatronSubscription]] =
50+
runQuery(
51+
subscriptions.filter(s => s.userId === userId && s.status === "ACTIVE")
52+
.sortBy(_.createdAt.desc).result.headOption
53+
)
54+
55+
override def findByProviderSubscriptionId(provider: String, providerSubId: String): Future[Option[PatronSubscription]] =
56+
runQuery(
57+
subscriptions.filter(s =>
58+
s.paymentProvider === provider && s.providerSubscriptionId === providerSubId
59+
).result.headOption
60+
)
61+
62+
override def findByStatus(status: String): Future[Seq[PatronSubscription]] =
63+
runQuery(subscriptions.filter(_.status === status).sortBy(_.createdAt.desc).result)
64+
65+
override def updateStatus(id: Int, status: String): Future[Boolean] =
66+
runQuery(
67+
subscriptions.filter(_.id === id)
68+
.map(s => (s.status, s.updatedAt))
69+
.update((status, LocalDateTime.now()))
70+
).map(_ > 0)
71+
72+
override def updatePeriod(id: Int, periodStart: LocalDateTime, periodEnd: LocalDateTime): Future[Boolean] =
73+
runQuery(
74+
subscriptions.filter(_.id === id)
75+
.map(s => (s.currentPeriodStart, s.currentPeriodEnd, s.updatedAt))
76+
.update((Some(periodStart), Some(periodEnd), LocalDateTime.now()))
77+
).map(_ > 0)
78+
79+
override def cancel(id: Int): Future[Boolean] =
80+
runQuery(
81+
subscriptions.filter(_.id === id)
82+
.map(s => (s.status, s.cancelledAt, s.updatedAt))
83+
.update(("CANCELLED", Some(LocalDateTime.now()), LocalDateTime.now()))
84+
).map(_ > 0)
85+
86+
override def countByTier(): Future[Map[String, Int]] =
87+
runQuery(
88+
subscriptions.filter(_.status === "ACTIVE")
89+
.groupBy(_.patronTier).map { case (tier, group) => (tier, group.length) }
90+
.result
91+
).map(_.toMap)
92+
93+
override def countActive(): Future[Int] =
94+
runQuery(subscriptions.filter(_.status === "ACTIVE").length.result)
95+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package services
2+
3+
import jakarta.inject.{Inject, Singleton}
4+
import models.domain.billing.{PatronSubscription, PatronSummary, PatronTier}
5+
import play.api.Logging
6+
import repositories.PatronSubscriptionRepository
7+
8+
import java.time.LocalDateTime
9+
import java.util.UUID
10+
import scala.concurrent.{ExecutionContext, Future}
11+
12+
@Singleton
13+
class PatronageService @Inject()(
14+
subscriptionRepo: PatronSubscriptionRepository
15+
)(implicit ec: ExecutionContext) extends Logging {
16+
17+
def createSubscription(
18+
userId: UUID,
19+
tier: String,
20+
billingInterval: String,
21+
paymentProvider: String,
22+
providerSubscriptionId: Option[String] = None,
23+
providerCustomerId: Option[String] = None
24+
): Future[Either[String, PatronSubscription]] = {
25+
if (!PatronSubscription.ValidTiers.contains(tier))
26+
return Future.successful(Left(s"Invalid patron tier: $tier"))
27+
if (!PatronSubscription.ValidIntervals.contains(billingInterval))
28+
return Future.successful(Left(s"Invalid billing interval: $billingInterval"))
29+
if (!PatronSubscription.ValidProviders.contains(paymentProvider))
30+
return Future.successful(Left(s"Invalid payment provider: $paymentProvider"))
31+
32+
subscriptionRepo.findActiveByUserId(userId).flatMap {
33+
case Some(existing) =>
34+
Future.successful(Left(s"User already has an active subscription (tier: ${existing.patronTier})"))
35+
case None =>
36+
val amountCents = PatronTier.amountCents(tier, billingInterval)
37+
val now = LocalDateTime.now()
38+
val periodEnd = billingInterval match {
39+
case "MONTHLY" => now.plusMonths(1)
40+
case "YEARLY" => now.plusYears(1)
41+
}
42+
43+
val subscription = PatronSubscription(
44+
userId = userId,
45+
patronTier = tier,
46+
paymentProvider = paymentProvider,
47+
providerSubscriptionId = providerSubscriptionId,
48+
providerCustomerId = providerCustomerId,
49+
amountCents = amountCents,
50+
billingInterval = billingInterval,
51+
currentPeriodStart = Some(now),
52+
currentPeriodEnd = Some(periodEnd)
53+
)
54+
subscriptionRepo.create(subscription).map(Right(_))
55+
}
56+
}
57+
58+
def cancelSubscription(subscriptionId: Int, userId: UUID): Future[Either[String, Boolean]] = {
59+
subscriptionRepo.findById(subscriptionId).flatMap {
60+
case None =>
61+
Future.successful(Left("Subscription not found"))
62+
case Some(sub) if sub.userId != userId =>
63+
Future.successful(Left("Not authorized to cancel this subscription"))
64+
case Some(sub) if sub.status != "ACTIVE" =>
65+
Future.successful(Left(s"Cannot cancel subscription with status: ${sub.status}"))
66+
case Some(_) =>
67+
subscriptionRepo.cancel(subscriptionId).map(Right(_))
68+
}
69+
}
70+
71+
def getActiveSubscription(userId: UUID): Future[Option[PatronSubscription]] =
72+
subscriptionRepo.findActiveByUserId(userId)
73+
74+
def getUserSubscriptions(userId: UUID): Future[Seq[PatronSubscription]] =
75+
subscriptionRepo.findByUserId(userId)
76+
77+
def handlePaymentWebhook(event: WebhookEvent, provider: String): Future[Either[String, Boolean]] = {
78+
subscriptionRepo.findByProviderSubscriptionId(provider, event.providerSubscriptionId).flatMap {
79+
case None =>
80+
logger.warn(s"Webhook for unknown subscription: ${event.providerSubscriptionId}")
81+
Future.successful(Left("Subscription not found"))
82+
case Some(sub) =>
83+
event.eventType match {
84+
case "subscription.renewed" | "invoice.paid" =>
85+
val start = event.periodStart.getOrElse(LocalDateTime.now())
86+
val end = event.periodEnd.getOrElse(
87+
if (sub.billingInterval == "MONTHLY") start.plusMonths(1) else start.plusYears(1)
88+
)
89+
for {
90+
_ <- subscriptionRepo.updateStatus(sub.id.get, "ACTIVE")
91+
_ <- subscriptionRepo.updatePeriod(sub.id.get, start, end)
92+
} yield Right(true)
93+
94+
case "subscription.cancelled" | "subscription.deleted" =>
95+
subscriptionRepo.cancel(sub.id.get).map(Right(_))
96+
97+
case "invoice.payment_failed" =>
98+
subscriptionRepo.updateStatus(sub.id.get, "PAST_DUE").map(Right(_))
99+
100+
case other =>
101+
logger.debug(s"Unhandled webhook event type: $other")
102+
Future.successful(Right(true))
103+
}
104+
}
105+
}
106+
107+
def expireOverdueSubscriptions(): Future[Int] = {
108+
subscriptionRepo.findByStatus("ACTIVE").flatMap { active =>
109+
val now = LocalDateTime.now()
110+
val expired = active.filter { sub =>
111+
sub.currentPeriodEnd.exists(_.isBefore(now))
112+
}
113+
114+
Future.sequence(expired.map { sub =>
115+
subscriptionRepo.updateStatus(sub.id.get, "EXPIRED")
116+
}).map(_.count(_ == true))
117+
}
118+
}
119+
120+
def getPatronSummary: Future[PatronSummary] = {
121+
for {
122+
activeCount <- subscriptionRepo.countActive()
123+
tierCounts <- subscriptionRepo.countByTier()
124+
} yield {
125+
val monthlyRevenue = tierCounts.map { case (tier, count) =>
126+
val monthlyAmount = PatronTier.amountCents(tier, "MONTHLY")
127+
monthlyAmount * count
128+
}.sum
129+
130+
PatronSummary(
131+
activePatrons = activeCount,
132+
tierCounts = tierCounts,
133+
monthlyRevenueCents = monthlyRevenue
134+
)
135+
}
136+
}
137+
138+
def isPatron(userId: UUID): Future[Boolean] =
139+
subscriptionRepo.findActiveByUserId(userId).map(_.isDefined)
140+
141+
def getPatronTier(userId: UUID): Future[Option[String]] =
142+
subscriptionRepo.findActiveByUserId(userId).map(_.map(_.patronTier))
143+
}

0 commit comments

Comments
 (0)