Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ env:
STRIPE_CHECKOUT_SUCCESS_URL: https://drebin451.com?billing=success
STRIPE_CHECKOUT_CANCEL_URL: https://drebin451.com?billing=cancelled
STRIPE_PORTAL_RETURN_URL: https://drebin451.com
STRIPE_RECONCILIATION_SCHEDULER_JOB: drebin451-stripe-reconciliation

jobs:
test:
Expand Down Expand Up @@ -207,6 +208,47 @@ jobs:
--update-env-vars "PUBLIC_BASE_URL=${{ env.PUBLIC_BASE_URL }},STRIPE_RETURN_URL=${{ env.STRIPE_RETURN_URL }},STRIPE_CHECKOUT_SUCCESS_URL=${{ env.STRIPE_CHECKOUT_SUCCESS_URL }},STRIPE_CHECKOUT_CANCEL_URL=${{ env.STRIPE_CHECKOUT_CANCEL_URL }},STRIPE_PORTAL_RETURN_URL=${{ env.STRIPE_PORTAL_RETURN_URL }},STRIPE_PRO_PRICE_ID=$STRIPE_PRO_PRICE_ID,B2_BUCKET=${{ env.B2_BUCKET }},B2_ENDPOINT=${{ env.B2_ENDPOINT }},B2_REGION=${{ env.B2_REGION }},FIREBASE_STORAGE_BUCKET=${{ env.FIREBASE_STORAGE_BUCKET }},DREBIN451_FIREBASE_WEB_API_KEY=$DREBIN451_FIREBASE_WEB_API_KEY" \
--update-secrets "FIREBASE_SERVICE_ACCOUNT_BASE64=drebin451-firebase-admin-json-base64:latest,B2_KEY_ID=drebin451-b2-key-id:latest,B2_APPLICATION_KEY=drebin451-b2-application-key:latest,STRIPE_SECRET_KEY=drebin451-stripe-secret-key:latest,STRIPE_WEBHOOK_SECRET=drebin451-stripe-webhook-secret:latest,DREBIN451_CRON_SECRET=drebin451-cron-secret:latest"

- name: Configure nightly Stripe reconciliation
env:
DREBIN451_CRON_SECRET: ${{ secrets.DREBIN451_CRON_SECRET }}
run: |
set -euo pipefail
if [ -z "$DREBIN451_CRON_SECRET" ]; then
echo "Required GitHub Actions secret DREBIN451_CRON_SECRET is missing." >&2
exit 1
fi

job="${{ env.STRIPE_RECONCILIATION_SCHEDULER_JOB }}"
uri="${{ env.PUBLIC_BASE_URL }}/v1/cron/stripe/reconcile"
common_args=(
--location="${{ env.REGION }}"
--project="${{ env.PROJECT_ID }}"
--schedule="0 4 * * *"
--time-zone="America/Chicago"
--uri="$uri"
--http-method=POST
--attempt-deadline=300s
--max-retry-attempts=3
)

if gcloud scheduler jobs describe "$job" \
--location="${{ env.REGION }}" \
--project="${{ env.PROJECT_ID }}" >/dev/null 2>&1
then
gcloud scheduler jobs update http "$job" \
"${common_args[@]}" \
--update-headers="X-Cron-Secret=$DREBIN451_CRON_SECRET"
else
gcloud scheduler jobs create http "$job" \
"${common_args[@]}" \
--headers="X-Cron-Secret=$DREBIN451_CRON_SECRET"
fi

gcloud scheduler jobs describe "$job" \
--location="${{ env.REGION }}" \
--project="${{ env.PROJECT_ID }}" \
--format='table(name.basename(),schedule,timeZone,state,httpTarget.uri)'

build-android:
needs: [test, changes]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.android == 'true'
Expand Down
13 changes: 9 additions & 4 deletions docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ encryption—so every base64 value below remains a GitHub Actions secret.
| `STRIPE_SECRET_KEY` | Stripe server API key | Secret Manager → Cloud Run `STRIPE_SECRET_KEY` |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | Secret Manager → Cloud Run `STRIPE_WEBHOOK_SECRET` |
| `STRIPE_PRO_PRICE_ID` | Production Stripe recurring price ID | Cloud Run `STRIPE_PRO_PRICE_ID` |
| `DREBIN451_CRON_SECRET` | Storage reconciliation scheduler credential | Secret Manager → Cloud Run `DREBIN451_CRON_SECRET` |
| `DREBIN451_CRON_SECRET` | Trusted storage and Stripe reconciliation scheduler credential | Secret Manager → Cloud Run `DREBIN451_CRON_SECRET` and Cloud Scheduler request header |
| `DREBIN451_FIREBASE_WEB_API_KEY` | Public Firebase Identity Toolkit client key used for password-reset email requests | Plain Cloud Run `DREBIN451_FIREBASE_WEB_API_KEY` environment variable |
| `DREBIN_API_KEY` | Upload-only Drebin451 API key used to publish the Android release | `Commit451/drebin451-release` action input |

Expand All @@ -32,11 +32,16 @@ identifier already observable in shipped Firebase clients, so it is passed to Cl
environment variable instead. Non-secret URLs, regions, buckets, price IDs, and project/service
names also stay in plain Cloud Run environment variables.

The server deployment also upserts the `drebin451-stripe-reconciliation` Cloud Scheduler job. It
posts to `/v1/cron/stripe/reconcile` every day at 4:00 AM in `America/Chicago`, authenticating with
`X-Cron-Secret`. The endpoint scans the small user collection and rebuilds billing-related plans
from Stripe's current subscription state; users with no billing history are skipped.

Deployment authentication and runtime Firebase access are intentionally separate. The dedicated
`github-actions-deploy@drebin451` account can deploy Cloud Run/Hosting, push Artifact Registry
images, and update only the pre-provisioned runtime secrets. The Firebase Admin account is not used
to deploy and should retain only the Firestore, Firebase Auth, FCM, and other explicitly required
runtime roles.
images, update only the pre-provisioned runtime secrets, and manage the dedicated Cloud Scheduler
job with `roles/cloudscheduler.admin`. The Firebase Admin account is not used to deploy and should
retain only the Firestore, Firebase Auth, FCM, and other explicitly required runtime roles.

## Local fallbacks

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ ktor-serverNetty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "kt
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages-jvm", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" }
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" }

# Ktor — client (shared)
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
Expand Down
1 change: 1 addition & 0 deletions server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ dependencies {
implementation(libs.aws.s3)
implementation(libs.apk.parser)
testImplementation(libs.kotlin.testJunit)
testImplementation(libs.ktor.server.test.host)
}
34 changes: 32 additions & 2 deletions server/src/main/kotlin/com/commit451/drebin451/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.commit451.drebin451.model.PlanLimits
import com.commit451.drebin451.model.VersionNote
import com.commit451.drebin451.model.storageStatus
import com.commit451.drebin451.stripe.StripeBilling
import com.commit451.drebin451.stripe.StripePlanReconciliationReport
import io.ktor.http.ContentDisposition
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
Expand Down Expand Up @@ -56,6 +57,7 @@ import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import io.ktor.server.routing.Route
import io.ktor.server.routing.routing
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -253,6 +255,8 @@ fun Application.module() {
)
}

stripePlanReconciliationRoute()

// Stripe sends unsigned browser redirects through Checkout/Portal, and signed lifecycle
// updates through this webhook. Webhooks are the source of truth for Pro entitlement.
post("/$prefix/stripe/webhook") {
Expand Down Expand Up @@ -822,8 +826,34 @@ fun Application.module() {
}
}

private suspend fun ApplicationCall.requireCronSecret(): Boolean {
val configured = configuredCronSecret()
internal fun Route.stripePlanReconciliationRoute(
configuredSecret: () -> String? = ::configuredCronSecret,
stripeConfigured: () -> Boolean = { StripeBilling.isConfigured },
reconcile: suspend () -> StripePlanReconciliationReport = { Firebasis.reconcileStripePlans() },
) {
post("/v1/cron/stripe/reconcile") {
if (!call.requireCronSecret(configuredSecret())) return@post
if (!stripeConfigured()) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ErrorResponse("Stripe billing is not configured"),
)
return@post
}
val report = reconcile()
call.application.log.info("Nightly Stripe reconciliation completed: $report")
val status = if (report.failedCount > 0) {
HttpStatusCode.InternalServerError
} else {
HttpStatusCode.OK
}
call.respond(status, report)
}
}

private suspend fun ApplicationCall.requireCronSecret(
configured: String? = configuredCronSecret(),
): Boolean {
if (configured == null) {
respond(
HttpStatusCode.ServiceUnavailable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import com.commit451.drebin451.storage.B2ObjectStorage
import com.commit451.drebin451.storage.StoredObject
import com.commit451.drebin451.storage.StoredObjectInfo
import com.commit451.drebin451.stripe.StripeBilling
import com.commit451.drebin451.stripe.StripePlanReconciliationReport
import com.commit451.drebin451.stripe.StripeSubscriptionUpdate
import com.commit451.drebin451.stripe.decodeReconciliationDocuments
import com.commit451.drebin451.stripe.reconcileScannedStripePlanUser
import com.commit451.drebin451.stripe.reconcileStripePlanUsers
import com.google.auth.oauth2.GoogleCredentials
import com.google.cloud.firestore.DocumentReference
import com.google.cloud.firestore.Firestore
Expand Down Expand Up @@ -247,6 +251,78 @@ object Firebasis {
}
}

/**
* Rebuilds every billing-related entitlement from Stripe's current subscription state. The
* collection scan is intentionally simple while the user count is small; users with neither a
* Stripe customer nor a locally stored Pro plan are skipped without making a Stripe request.
*/
suspend fun reconcileStripePlans(): StripePlanReconciliationReport {
check(StripeBilling.isConfigured) { "Stripe billing is not configured" }
val documents = firestore.collection(CollectionUsers)
.get()
.await()
.documents
val decodedUsers = decodeReconciliationDocuments(
documents = documents,
decode = { document -> document.toObject(User::class.java) },
onFailure = { document, failure ->
log.warn(
"Failed nightly Stripe reconciliation to decode Firestore user document ${document.id}",
failure,
)
},
)
return reconcileStripePlanUsers(
users = decodedUsers.values,
decodeFailureCount = decodedUsers.failedCount,
refresh = { user -> reconcileStripePlan(user) },
onFailure = { user, failure ->
log.warn("Failed nightly Stripe reconciliation for user ${user.uid}", failure)
},
)
}

private suspend fun reconcileStripePlan(user: User): User =
reconcileScannedStripePlanUser(
scannedUser = user,
verifyAndDowngradeOrphan = { scanned ->
downgradeStripeOrphanIfStillCurrent(scanned.uid)
},
refreshCanonicalState = { current -> refreshStripePlan(current.uid) },
)

private suspend fun downgradeStripeOrphanIfStillCurrent(uid: String): User {
val ref = userDocument(uid)
val syncedAt = System.currentTimeMillis()
return firestore.runTransaction { txn ->
val current = txn.get(ref).get().toObject(User::class.java)
?: throw IllegalArgumentException("User not found")
if (current.stripeCustomerId.isNotBlank()) return@runTransaction current

val currentPlan = PlanLimits.normalized(current.plan)
val updated = current.copy(
stripeSubscriptionStatus = "none",
plan = PlanIds.FREE,
planUpdatedAt = if (currentPlan != PlanIds.FREE || current.planUpdatedAt == 0L) {
syncedAt
} else {
current.planUpdatedAt
},
planSyncedAt = syncedAt,
)
txn.update(
ref,
mapOf(
"stripeSubscriptionStatus" to updated.stripeSubscriptionStatus,
"plan" to updated.plan,
"planUpdatedAt" to updated.planUpdatedAt,
"planSyncedAt" to updated.planSyncedAt,
),
)
updated
}.await()
}

suspend fun applyStripeSubscriptionUpdate(
update: StripeSubscriptionUpdate,
syncedAt: Long = System.currentTimeMillis(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,28 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Duration
import java.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs

internal val StripeConnectTimeout: Duration = Duration.ofSeconds(10)
internal val StripeRequestTimeout: Duration = Duration.ofSeconds(30)

internal fun stripeHttpClient(): HttpClient = HttpClient.newBuilder()
.connectTimeout(StripeConnectTimeout)
.build()

internal fun stripeRequestBuilder(uri: URI, secretKey: String): HttpRequest.Builder =
HttpRequest.newBuilder()
.uri(uri)
.timeout(StripeRequestTimeout)
.header("Authorization", "Bearer $secretKey")

object StripeBilling {
private val log = LoggerFactory.getLogger(StripeBilling::class.java)
private val client = HttpClient.newHttpClient()
private val client = stripeHttpClient()
private val json = Json { ignoreUnknownKeys = true }

private val secretKey: String?
Expand Down Expand Up @@ -295,9 +309,7 @@ object StripeBilling {

private suspend fun postForm(path: String, params: List<Pair<String, String>>): JsonObject {
val secret = requireSecretKey()
val request = HttpRequest.newBuilder()
.uri(URI.create("https://api.stripe.com$path"))
.header("Authorization", "Bearer $secret")
val request = stripeRequestBuilder(URI.create("https://api.stripe.com$path"), secret)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formEncode(params)))
.build()
Expand All @@ -307,19 +319,15 @@ object StripeBilling {
private suspend fun get(path: String, params: List<Pair<String, String>>): JsonObject {
val secret = requireSecretKey()
val query = formEncode(params)
val request = HttpRequest.newBuilder()
.uri(URI.create("https://api.stripe.com$path?$query"))
.header("Authorization", "Bearer $secret")
val request = stripeRequestBuilder(URI.create("https://api.stripe.com$path?$query"), secret)
.GET()
.build()
return send(request, path)
}

private suspend fun delete(path: String): JsonObject {
val secret = requireSecretKey()
val request = HttpRequest.newBuilder()
.uri(URI.create("https://api.stripe.com$path"))
.header("Authorization", "Bearer $secret")
val request = stripeRequestBuilder(URI.create("https://api.stripe.com$path"), secret)
.DELETE()
.build()
return send(request, path)
Expand Down
Loading