diff --git a/.github/workflows/billing-canary.yml b/.github/workflows/billing-canary.yml new file mode 100644 index 00000000..88ee6b56 --- /dev/null +++ b/.github/workflows/billing-canary.yml @@ -0,0 +1,75 @@ +name: Billing canary + +# Answers "can a customer pay right now?" hourly, which no other workflow does. +# +# `cloud-smoke` checks the marketing site returns 200. `dashboard-ci` checks the build. Neither can +# see a broken purchase path: a rotated key, a deactivated store product or an unreachable webhook +# leaves every one of their checks green. This runs scripts/billing-canary.sh, whose exit codes are +# designed for a scheduler — 1 = a purchase path is broken, 2 = the canary could not run, which is +# deliberately NOT treated as healthy. +# +# Scheduled on a public repo, so GitHub Actions minutes are free (PayCraft is public; the rule of +# thumb for this org is that private repos do not get scheduled workflows). + +on: + workflow_dispatch: + workflow_call: + schedule: + # Hourly at :41. Offset from cloud-smoke (:23) and the pg_cron canary (:07) so a shared + # failure shows up as three distinct timestamps rather than one ambiguous pile. + - cron: "41 * * * *" + +permissions: + contents: read + +jobs: + canary: + name: Can a customer pay? + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install psql client + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq postgresql-client + + - name: Run billing canary + id: canary + env: + # L1 — provider config truth via /api/v1/readiness + PAYCRAFT_ACCOUNT_KEY: ${{ secrets.PAYCRAFT_ACCOUNT_KEY }} + # L2 — webhook delivery verdicts (migration 165). PG* rather than a URL so no + # credential-bearing string lands in a process listing or a log line. + PGHOST: ${{ secrets.PAYCRAFT_PGHOST }} + PGPORT: ${{ secrets.PAYCRAFT_PGPORT }} + PGUSER: ${{ secrets.PAYCRAFT_PGUSER }} + PGPASSWORD: ${{ secrets.PAYCRAFT_PGPASSWORD }} + PGDATABASE: ${{ secrets.PAYCRAFT_PGDATABASE }} + PGSSLMODE: require + # L3 — chain truth. Absent until a canary tenant exists; the script reports BLOCKED + # (exit 2) rather than skipping silently. + PAYCRAFT_CANARY_TENANT_ID: ${{ secrets.PAYCRAFT_CANARY_TENANT_ID }} + PAYCRAFT_CANARY_SKU: ${{ secrets.PAYCRAFT_CANARY_SKU }} + PAYCRAFT_CANARY_API_KEY: ${{ secrets.PAYCRAFT_CANARY_API_KEY }} + STRIPE_TEST_KEY: ${{ secrets.PAYCRAFT_STRIPE_TEST_KEY }} + run: | + set +e + bash scripts/billing-canary.sh + CODE=$? + echo "code=$CODE" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Fail on broken purchase path + if: steps.canary.outputs.code == '1' + run: | + echo "::error title=Purchase path broken::A provider cannot transact. See the canary log above." + exit 1 + + # Exit 2 is surfaced as a WARNING, not a failure. A missing canary credential is an + # operational gap to close, not an outage — but it must stay visible, because a canary that + # cannot run looks exactly like a canary that found nothing wrong. + - name: Warn on blocked canary + if: steps.canary.outputs.code == '2' + run: | + echo "::warning title=Canary blocked::Checks could not run (missing credential or unreachable API). This is NOT a pass." diff --git a/.github/workflows/cloud-smoke.yml b/.github/workflows/cloud-smoke.yml index 0138d695..03e2ef0f 100644 --- a/.github/workflows/cloud-smoke.yml +++ b/.github/workflows/cloud-smoke.yml @@ -3,6 +3,12 @@ name: Production smoke tests on: workflow_call: workflow_dispatch: + # Unattended, every 6 hours. Until 2026-10-22 this workflow had NO schedule — it ran only when + # the deploy called it or a human clicked it, so between deploys nothing watched production at + # all. A smoke test that only runs at deploy time cannot catch what breaks after a deploy, which + # is the entire class of outage worth catching. + schedule: + - cron: "23 */6 * * *" jobs: smoke: diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt index 7504a334..919dc14c 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt @@ -274,6 +274,33 @@ object PayCraft { */ val plans: List get() = config?.plans ?: emptyList() + /** + * The subscription-ish half of [plans] — everything a PAYWALL may offer. + * + * [plans] is the whole catalogue (that is what [products] subsets for the store), so once + * consumables started reaching it the paywall surfaces had to say which half they meant. They + * pick by RANK — `minByOrNull` for the cheapest, `maxByOrNull` for the top tier — and rank is + * just catalogue order, so an unfiltered pick could render "300 Ripples" as the upgrade a free + * user is offered. A coin pack is not an upgrade path. + * + * Lifetime and one_time unlocks STAY: they are real entitlements a paywall legitimately sells, + * and they were in this list before. Only consumables leave. + */ + public val subscriptionPlans: List get() = plans.filterNot { it.isConsumable } + + /** + * The non-subscription catalogue — consumable packs and one-time unlocks. + * + * Deliberately EXCLUDES subscription and trial plans (they belong to the paywall): a store that + * lists a plan beside a coin pack invites a user to "buy" a subscription they may already hold, + * and the two are bought through different flows. + * + * Derived from the same [plans] the paywall renders rather than a second fetch, so the two + * surfaces can never quote different prices for one sku. + */ + val products: List + get() = plans.filter { it.isConsumable || it.interval == "lifetime" } + /** * The native store's OWN localized price per plan sku (Play `formattedPrice` / StoreKit * `displayPrice`), resolved after products load on native billing lanes. When present it is @@ -1490,10 +1517,29 @@ private fun List.toBillingPlans( popularSku: String?, nativePricesBySku: Map = emptyMap(), ): List { - val subscriptions = filter { it.type == "subscription" || it.type == "lifetime" } + // EVERY sellable type, and a DENYLIST rather than an allowlist — deliberately. + // + // This was `filter { type == "subscription" || type == "lifetime" }`, which dropped + // `consumable` and `one_time` BEFORE the map below ever ran. The cost was not a missing row + // in one list: it made `isConsumable = dto.type == "consumable"` (further down) UNREACHABLE, + // so the flag was permanently false for every product in the app, and three unrelated things + // broke in ways that each looked like their own bug — + // + // * PayCraftStoreSection rendered "No products available" against a tenant that had packs; + // * ConsumableDrainer's `plans.filter { it.isConsumable }` matched nothing, so purchased + // units were never drained into the host (the balance just sat on the server); + // * PayCraftBillingManager.isConsumableStoreProduct returned false, so a pack purchase was + // ACKNOWLEDGED instead of CONSUMED — Play then refuses to sell it again, permanently. + // + // An allowlist is what let that happen: a type the server learned and this list did not + // silently vanishes, and the symptom appears far from the cause. A denylist fails the other + // way — a new type SHOWS UP, where it is noticed. `trial` is the one genuine exclusion, + // because a trial is not independently sellable: it attaches to a parent product and is + // folded into it as `trialDays` below. + val sellable = filter { it.type != "trial" } .sortedBy { it.displayOrder } val trials = filter { it.type == "trial" } - return subscriptions.mapIndexed { idx, dto -> + return sellable.mapIndexed { idx, dto -> val attachedTrial = trials.firstOrNull { it.attachesToProductId == dto.id } val trialDays = when { attachedTrial != null -> attachedTrial.trialDurationDays @@ -1549,6 +1595,7 @@ private fun List.toBillingPlans( // "lifetime", which makes a consumable indistinguishable from a one-time unlock — // and Play finishes those two with different calls. isConsumable = dto.type == "consumable", + grantQuantity = dto.grantQuantity, ) } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt index 13f72a35..d38e213c 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/config/SuiteConfig.kt @@ -64,7 +64,7 @@ data class SuiteConfig( data class ProductDto( val id: String, val sku: String, - val type: String, // "subscription" | "trial" | "lifetime" + val type: String, // "subscription" | "trial" | "lifetime" | "consumable" @SerialName("display_name") val displayName: String, @SerialName("trial_enabled") val trialEnabled: Boolean = true, @SerialName("trial_duration_days") val trialDurationDays: Int? = 7, @@ -73,6 +73,13 @@ data class ProductDto( @SerialName("base_price_cents") val basePriceCents: Int = 0, @SerialName("base_currency") val baseCurrency: String = "USD", @SerialName("display_order") val displayOrder: Int = 0, + /** + * How many units ONE purchase of a consumable grants (migration 158's `tenant_products + * .grant_quantity`). 0 for every other product type, and 0 is also the safe default for an + * older server that does not send the field — a pack that grants nothing is visibly broken, + * whereas a guessed non-zero default would silently credit currency nobody configured. + */ + @SerialName("grant_quantity") val grantQuantity: Int = 0, val active: Boolean = true, @SerialName("resolved_price") val resolvedPrice: PriceDto? = null, /** diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt index c08cf592..c182bdc8 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/BillingManager.kt @@ -3,6 +3,7 @@ package com.mobilebytelabs.paycraft.core import com.mobilebytelabs.paycraft.model.BillingState import com.mobilebytelabs.paycraft.model.OAuthProvider import com.mobilebytelabs.paycraft.model.SubscriptionStatus +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -146,4 +147,65 @@ internal interface BillingManager { suspend fun revokeCurrentDevice() fun logOut() + + // ─── Consumable balances (REQUIREMENTS.md PC-001..006) ─────────────────── + + /** + * Server-authoritative consumable balances, keyed by sku. + * + * READ-ONLY and there is deliberately no local increment path (AR-013): a client-writable + * balance is forgeable currency. Every value here came from the server — either a + * [refreshBalance] read or the new balance a successful [spend] returned. A sku absent from the + * map has simply not been read yet; it is NOT the same as a zero balance. + * + * Default empty so alternate [BillingManager] implementations stay source-compatible. + */ + val balances: StateFlow> get() = MutableStateFlow(emptyMap()) + + /** + * Re-read [sku]'s balance from the server and publish it to [balances]. + * + * @return the balance, or 0 when it cannot be read. 0 is the safe reading of "unknown" — it + * withholds a spend rather than granting one. + */ + suspend fun refreshBalance(sku: String): Int = 0 + + /** + * Spend [quantity] of [sku] atomically, server-checked. + * + * [idempotencyKey] is REQUIRED and has no default (AR-011). A caller retrying after a timeout + * must present the SAME key, so the server recognises the replay and returns the original + * balance instead of charging twice. Only the caller knows which retry is the same logical + * spend, which is why this cannot be defaulted here. + * + * @return the new balance on success. Insufficient funds is a [Result.failure], NOT an + * exception (AR-012) — a shopper short on currency is an expected outcome of a shop. Never + * grant the purchased thing on a failure. + */ + suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result = + Result.failure(UnsupportedOperationException("balances not supported by this BillingManager")) + + /** + * Credit an in-app ACTIVITY earn and return the new server balance. + * + * Throws rather than returning a Result: unlike a spend, there is no "refused" outcome a caller + * renders — the server either records it or the call failed, and PayCraftBalances queues the + * latter for replay. + */ + suspend fun earn(sku: String, quantity: Int, eventId: String): Int = + throw UnsupportedOperationException("balances not supported by this BillingManager") + + // ─── One-time product ownership ────────────────────────────────────────── + + /** + * Skus this user owns outright (non-consumable one-time unlocks). + * + * Server-confirmed only. A sku is absent until [refreshOwnership] gets a `true` back — never + * optimistically on purchase return, because Play and StoreKit can both report success for a + * transaction that later fails verification. + */ + val ownedSkus: StateFlow> get() = MutableStateFlow(emptySet()) + + /** Re-read ownership of [sku] from the server into [ownedSkus]. False when it cannot be read. */ + suspend fun refreshOwnership(sku: String): Boolean = false } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftAdRewards.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftAdRewards.kt new file mode 100644 index 00000000..634bb605 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftAdRewards.kt @@ -0,0 +1,104 @@ +package com.mobilebytelabs.paycraft.core + +import com.mobilebytelabs.paycraft.debug.PayCraftLogger + +/** + * Watch an ad, earn currency — the third way units enter a balance, beside buying and in-app effort. + * + * ## Why the SDK does not show the ad + * + * PayCraft owns the MONEY path, not someone else's ad SDK. AdMob, AppLovin and the rest each take a + * firm position on Activity / UIViewController ownership and ship large platform-specific + * dependencies; adopting one would force it on every consumer, including those who sell no ads at + * all. So the host shows the ad — it already has the network it chose — and binds this seam exactly + * the way it binds `identity`. Everything after "the ad completed" is the SDK's. + * + * ## What makes the reward trustworthy + * + * **Not this interface.** A client saying "I watched an ad" is a client minting currency with no + * attention actually sold — the same shape as the subject-id hole migration 159 closed, and the + * email hole 162 closed. So completing an ad here credits NOTHING directly. + * + * Instead the ad network's own **server-side verification (SSV)** callback reaches PayCraft's + * `ad-reward` edge function, which checks the network's signature before crediting + * `entitlement_balance_ledger` with `reason = 'ad_reward'` via a service-role RPC that has no + * client-callable overload. This class's only job is to carry enough context in the ad request for + * that callback to know WHO and WHICH SKU — and then to reconcile, because the credit lands + * server-side and out of band. + * + * ## Not to be confused with `PayCraftAdUnlock` + * + * [PayCraftAdUnlock] grants a time-boxed FEATURE unlock and is deliberately local-only: a free + * feature has no transferable value, so a round-trip to prove it would cost more than it protects. + * Currency does have transferable value. The two must not be merged, and this one can never be + * local. + */ +public fun interface AdRewardProvider { + + /** + * Show a rewarded ad and return true only if the user EARNED the reward (watched to the point + * the network rewards), false if they dismissed it early or no ad was available. + * + * [customData] must be passed to the ad network verbatim as its custom-data / server-side + * verification payload (AdMob: `ServerSideVerificationOptions.customData`). It is how the SSV + * callback attributes the reward to this device and sku — without it the callback arrives with + * nothing to credit, and the user watches an ad for nothing. + * + * Throwing is treated as "no reward". + */ + public suspend fun showRewardedAd(customData: String, userId: String): Boolean +} + +/** + * Drives one watch-to-earn round: show the ad, then adopt whatever the server says. + * + * Deliberately returns the RECONCILED balance rather than "how much you earned". The client never + * learns the reward amount from the ad — the network's callback decides it server-side, and a + * client-computed delta would be a number the user could disagree with and the server would + * overrule a moment later. + */ +public class PayCraftAdRewards internal constructor( + private val provider: AdRewardProvider?, + private val balances: PayCraftBalances, + private val deviceToken: () -> String?, +) { + + /** True when the host bound a provider — hosts hide the watch-to-earn affordance when false. */ + public val isAvailable: Boolean get() = provider != null + + /** + * Show a rewarded ad for [sku] and return the balance after reconciling. + * + * Returns null when no provider is bound, the device is unregistered, or the user did not earn + * the reward — all of which are ordinary outcomes a host renders as "no reward this time", + * never as an error. + * + * The reconcile is not optional and not instant: the SSV callback is an out-of-band server call + * that may land after this returns. A host that shows a stale balance here and never reconciles + * again will leave the user believing the ad paid nothing. + */ + public suspend fun watchAndEarn(sku: String): Int? { + val ads = provider ?: return null + val token = deviceToken() + if (token.isNullOrBlank()) { + // No registered device means the callback has nothing to attribute the reward to, so + // showing the ad would spend the user's attention for certain nothing. + PayCraftLogger.onError("ad-reward", "no registered device — not showing an ad") + return null + } + + // sku and device token, in the shape the ad-reward edge function parses. The token (not a + // subject id) for the same reason every other client call carries one: a caller must only + // ever be able to name ITSELF. + val customData = "$token|$sku" + + val earned = runCatching { ads.showRewardedAd(customData, token) }.getOrDefault(false) + if (!earned) return null + + // The credit is already in flight server-side; this adopts it. If the callback has not + // landed yet the number is simply unchanged, and the next reconcile picks it up — which is + // why the balance is a flow the host observes rather than a one-shot answer. + balances.reconcile(sku) + return balances.balanceOf(sku) + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalances.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalances.kt new file mode 100644 index 00000000..ed0cf215 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalances.kt @@ -0,0 +1,167 @@ +package com.mobilebytelabs.paycraft.core + +import com.mobilebytelabs.paycraft.debug.PayCraftLogger +import com.mobilebytelabs.paycraft.persistence.PayCraftStore +import com.mobilebytelabs.paycraft.repository.PayCraftRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The spendable balance, owned end to end by the SDK. + * + * ## What replaced what + * + * This replaces `ConsumableGrantSink` + `ConsumableDrainer`, which asked the HOST to keep its own + * ledger and drained the server's balance into it. That was two sources of truth for one currency: + * the user saw the host's number, the server held a different one, and a reconciliation bug showed + * up as money that vanished. It also had no room for a third earn source — an ad reward credited + * server-side would have needed draining too, and one credited host-side would have been invisible + * to the server. + * + * Now the server's `entitlement_balance_ledger` is the single record, and every source lands in it: + * + * ``` + * purchase store / PSP webhook + * ad_reward ad-network SSV callback (server-verified) + * activity the app's own earn events (capped server-side) + * spend this class + * ``` + * + * The host keeps no ledger and writes no reconciliation. It reads [balanceOf] and calls [spend]. + * + * ## Why there is still a local mirror + * + * A server-of-record balance would make every spend an online call, which breaks an app that works + * offline — cappy's shop renders and spends with no network at all. Rather than push that problem + * onto each consumer, the SDK holds the mirror and an outbox: a spend applies locally at once and + * is replayed when the network returns. + * + * **The server always wins.** The mirror is a cache, never an authority: every reconcile overwrites + * it with the server's number, including downward. A client that could decide its own balance would + * be minting currency people pay real money for (AR-013), so nothing here computes a balance — it + * only remembers the last one the server reported, adjusted by spends not yet acknowledged. + * + * ## The honest limit + * + * Two devices offline at once can both spend the same units, and the server will accept only what + * exists. The second device's spend fails on reconcile and its balance corrects downward — the user + * sees units they appeared to have disappear. That is acceptable for cosmetics and is NOT acceptable + * for anything with real value, which is why a sku can opt out via [offlineSpendAllowed] and take + * the online-only path instead. + */ +public class PayCraftBalances internal constructor( + private val repository: PayCraftRepository, + private val store: PayCraftStore, + private val newIdempotencyKey: () -> String, +) { + + private val mirror = MutableStateFlow>(emptyMap()) + + /** Last known balance per sku — server truth, minus spends still in the outbox. */ + public val balances: StateFlow> = mirror.asStateFlow() + + public fun balanceOf(sku: String): Int = mirror.value[sku] ?: 0 + + /** + * Spend [quantity] of [sku]. + * + * Applies to the mirror immediately so the UI responds at once, and enqueues the server call. + * Returns false ONLY when the local balance cannot cover it — an offline spend that the server + * later rejects is reported through [reconcile], not here, because at call time there is no way + * to know. + * + * @param offlineSpendAllowed when false this refuses rather than queueing while offline. Use it + * for anything whose over-spend cannot be forgiven; the default suits a cosmetic economy. + */ + public suspend fun spend( + sku: String, + quantity: Int, + offlineSpendAllowed: Boolean = true, + ): Boolean { + require(quantity >= 1) { "quantity must be >= 1" } + if (balanceOf(sku) < quantity) return false + + val key = newIdempotencyKey() + // Optimistic FIRST: the user sees the cost deducted as they tap, which is the whole point of + // the mirror. The outbox row is written before the network call so a crash mid-flight + // replays rather than losing the spend. + mirror.value = mirror.value + (sku to (balanceOf(sku) - quantity)) + store.enqueueSpend(key, sku, quantity) + + val result = runCatching { repository.spend(sku, quantity, key) }.getOrNull() + return when { + result == null && !offlineSpendAllowed -> { + // Online-only sku: undo and refuse rather than leave an unacknowledged debit. + store.dequeueSpend(key) + mirror.value = mirror.value + (sku to (balanceOf(sku) + quantity)) + false + } + result == null -> true // queued; reconcile replays it + result.isSuccess -> { + store.dequeueSpend(key) + mirror.value = mirror.value + (sku to result.getOrDefault(balanceOf(sku))) + true + } + else -> { + // The server REFUSED (insufficient funds server-side). Drop the outbox row — a + // retry would be refused identically — and let reconcile restore the true number. + store.dequeueSpend(key) + PayCraftLogger.onError("balance", "spend refused for $sku; reconciling") + reconcile(sku) + false + } + } + } + + /** + * Report an in-app earn (a completed focus session, a streak). Safe to call offline — it is + * queued and replayed. + * + * [eventId] must be STABLE for the activity that caused it, because it IS the idempotency key: + * replaying a sync must not pay twice. The server clamps to the tenant's daily cap, so the + * returned balance may rise by less than [quantity] — that is the cap working, not an error. + */ + public suspend fun earn(sku: String, quantity: Int, eventId: String): Int { + require(quantity >= 1) { "quantity must be >= 1" } + require(eventId.isNotBlank()) { "eventId is required — it is the idempotency key" } + + store.enqueueEarn(eventId, sku, quantity) + val result = runCatching { repository.earn(sku, quantity, eventId) }.getOrNull() + if (result != null) { + store.dequeueEarn(eventId) + mirror.value = mirror.value + (sku to result) + return result + } + // Offline: show it optimistically. The server's clamp may lower it on reconcile, which is + // the correct direction to be wrong in — better to over-show briefly than to hide earned + // units until the next connection. + val optimistic = balanceOf(sku) + quantity + mirror.value = mirror.value + (sku to optimistic) + return optimistic + } + + /** + * Drain the outbox and adopt the server's number. Safe to call on every start and foreground. + * + * Order matters: queued writes are replayed BEFORE the read, or the read would return a balance + * that does not yet reflect them and the mirror would bounce. + */ + public suspend fun reconcile(sku: String) { + store.pendingEarns().filter { it.sku == sku }.forEach { pending -> + runCatching { repository.earn(pending.sku, pending.quantity, pending.id) } + .onSuccess { store.dequeueEarn(pending.id) } + } + store.pendingSpends().filter { it.sku == sku }.forEach { pending -> + runCatching { repository.spend(pending.sku, pending.quantity, pending.id) } + .onSuccess { store.dequeueSpend(pending.id) } + } + runCatching { repository.refreshBalance(sku) } + .onSuccess { mirror.value = mirror.value + (sku to it) } + } + + /** Reconcile every consumable sku in the catalogue. */ + public suspend fun reconcileAll() { + repository.plans.value.filter { it.isConsumable }.forEach { reconcile(it.sku) } + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt index d6296d2b..31758ba4 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBillingManager.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.mobilenativefoundation.store.store5.StoreReadResponse @@ -110,6 +111,16 @@ class PayCraftBillingManager( private val _isPremium = MutableStateFlow(false) override val isPremium: StateFlow = _isPremium.asStateFlow() + // Consumable balances. ONLY ever written from a server answer — a refreshBalance() read or the + // new balance a successful spend() returned (AR-013: a client-writable balance is forgeable + // currency, so there is no local increment path and no setter). + private val _balances = MutableStateFlow>(emptyMap()) + override val balances: StateFlow> = _balances.asStateFlow() + + // Owned one-time skus. Like _balances, only ever written from a server answer. + private val _ownedSkus = MutableStateFlow>(emptySet()) + override val ownedSkus: StateFlow> = _ownedSkus.asStateFlow() + private val _subscriptionStatus = MutableStateFlow(SubscriptionStatus()) override val subscriptionStatus: StateFlow = _subscriptionStatus.asStateFlow() @@ -1095,4 +1106,73 @@ class PayCraftBillingManager( lastObservedPremium = false } } + + // ─── Consumable balances ──────────────────────────────────────────────────────────────────── + + /** + * Read [sku]'s balance from the server and publish it to [balances]. + * + * The subject is NOT a parameter: the server derives it from this device's token (migration + * 159). Before that, these RPCs took a caller-supplied subject id, which both let one holder of + * the publishable key read another user's balance AND made the call impossible to make honestly + * — `entitlement_subject_resolve` is service_role-only, so a client could never learn its own + * subject id to pass. + * + * No token (signed out, not yet registered) is 0 WITHOUT a server round-trip, and the map is + * left untouched: publishing a 0 here would be indistinguishable from a real zero balance and + * would make the UI offer a spend that cannot succeed. + */ + /** + * Read ownership of [sku] from the server into [ownedSkus]. + * + * Note this REMOVES the sku on a false, rather than only adding on a true. A refund or a failed + * verification has to be able to close a gate that an earlier purchase opened; an add-only set + * would leave content unlocked forever after the first true. + */ + override suspend fun refreshOwnership(sku: String): Boolean { + val token = DeviceTokenStore.getToken() + if (token.isNullOrBlank()) return false + val owned = service.owns(token, sku) + _ownedSkus.update { if (owned) it + sku else it - sku } + return owned + } + + override suspend fun refreshBalance(sku: String): Int { + val token = DeviceTokenStore.getToken() + if (token.isNullOrBlank()) return 0 + val balance = service.balanceOf(token, sku) + _balances.update { it + (sku to balance) } + return balance + } + + /** + * Spend [quantity] of [sku], server-checked and atomic. + * + * The service returns null for BOTH a transport failure and `insufficient_balance`, and the + * distinction cannot be recovered here. Both map to [Result.failure] — which is the safe + * collapse, because the one outcome that must never happen is treating a network error as a + * successful spend and handing over the goods. The caller's message is the same either way. + */ + override suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result { + val token = DeviceTokenStore.getToken() + if (token.isNullOrBlank()) { + return Result.failure(IllegalStateException("no registered device - cannot spend")) + } + val newBalance = service.spendBalance(token, sku, quantity, idempotencyKey) + ?: return Result.failure(IllegalStateException("spend rejected (insufficient balance or unreachable)")) + // Server-returned value, so this is still a server write (AR-013), not a local decrement. + _balances.update { it + (sku to newBalance) } + return Result.success(newBalance) + } + + override suspend fun earn(sku: String, quantity: Int, eventId: String): Int { + val token = DeviceTokenStore.getToken() + ?: throw IllegalStateException("no registered device - cannot earn") + if (token.isBlank()) throw IllegalStateException("no registered device - cannot earn") + val newBalance = service.earnBalance(token, sku, quantity, eventId) + ?: throw IllegalStateException("earn rejected (unreachable, or sku has no daily_earn_cap)") + // Server-returned, and possibly CLAMPED below what was asked — the cap working, not an error. + _balances.update { it + (sku to newBalance) } + return newBalance + } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftSetup.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftSetup.kt index 5d5e7f85..b739affb 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftSetup.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftSetup.kt @@ -1,3 +1,4 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) /* * Copyright 2026 MobileByteLabs * @@ -11,6 +12,7 @@ import com.mobilebytelabs.paycraft.InitOptions import com.mobilebytelabs.paycraft.PayCraft import com.mobilebytelabs.paycraft.PayCraftBackend import com.mobilebytelabs.paycraft.core.MonetizationMode +import com.mobilebytelabs.paycraft.persistence.PayCraftStore import com.mobilebytelabs.paycraft.repository.PayCraftRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,8 +23,14 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.module.Module +import com.mobilebytelabs.paycraft.core.AdRewardProvider +import com.mobilebytelabs.paycraft.core.PayCraftAdRewards +import com.mobilebytelabs.paycraft.core.PayCraftBalances +import com.mobilebytelabs.paycraft.platform.DeviceTokenStore import org.koin.core.scope.Scope import org.koin.dsl.module +import kotlin.random.Random +import kotlin.time.Clock /** * The ONE call that wires PayCraft into a consumer app. @@ -85,9 +93,35 @@ fun payCraft( options: InitOptions = InitOptions(), mode: MonetizationMode = MonetizationMode.AdSupported, identity: Scope.() -> Flow = { emptyFlow() }, + /** + * HOW TO SHOW A REWARDED AD, for apps that let users earn currency by watching one. + * + * Bound like [identity] and for the same reason: the host already has the ad network it chose, + * and PayCraft owning an ad SDK would force AdMob (or whichever) on every consumer, including + * those who sell no ads. The host shows the ad; everything after it completes is the SDK's. + * + * Completing an ad here credits NOTHING by itself — the ad network's signed server-side + * verification callback does, via the `ad-reward` edge function. A client that could assert "I + * watched an ad" would be minting currency with no attention sold. + * + * Omit it and watch-to-earn is simply unavailable (`PayCraft.adRewards.isAvailable == false`). + */ + adProvider: (Scope.() -> AdRewardProvider)? = null, ): Module = module { includes(PayCraftModule) + // The balance mirror + outbox. Single instance: two would each hold a different idea of the + // balance, which is the exact two-ledger defect this replaced. + single { + PayCraftBalances( + repository = get(), + store = get(), + newIdempotencyKey = { "spend-${Clock.System.now().toEpochMilliseconds()}-${Random.nextInt(Int.MAX_VALUE)}" }, + ) + } + + single { PayCraftAdRewards(adProvider?.invoke(this), get(), { DeviceTokenStore.getToken() }) } + // createdAtStart, and that is load-bearing: see (3) in the kdoc above. single(createdAtStart = true) { PayCraftIdentityBinder( @@ -97,6 +131,9 @@ fun payCraft( // A lambda, not a value: the SDK's own definitions only become resolvable once // `initialize` has run, which happens inside the binder's constructor below. See (2). repository = { getKoin().getOrNull() }, + // Same lambda shape and same reason: the SDK's own definitions only resolve once + // `initialize` has run, which happens inside the binder's constructor. + store = { getKoin().getOrNull() }, apiKey = apiKey, testApiKey = testApiKey, backend = backend, @@ -115,6 +152,7 @@ fun payCraft( internal class PayCraftIdentityBinder( email: Flow, repository: () -> PayCraftRepository?, + store: () -> PayCraftStore?, apiKey: String, testApiKey: String?, backend: PayCraftBackend, @@ -144,5 +182,6 @@ internal class PayCraftIdentityBinder( // identity rather than being told the user is "". .collect { address -> if (address.isNotBlank()) repository()?.logIn(address) } } + } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/BillingPlan.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/BillingPlan.kt index ae126d2b..5ee66921 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/BillingPlan.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/model/BillingPlan.kt @@ -89,6 +89,14 @@ data class BillingPlan( * rejected with "already owned". See [com.mobilebytelabs.paycraft.billing.NativeBillingClient.finishPurchase]. */ val isConsumable: Boolean = false, + /** + * Units granted per purchase when [isConsumable]; 0 otherwise. + * + * The host needs this to label the pack ("550 ⚡") and to know what one purchase is worth. It + * is NOT what credits the balance — the server does that from the store webhook, because a + * client that could decide its own grant could mint currency (AR-013). + */ + val grantQuantity: Int = 0, ) { init { require(trialDays == null || trialDays >= 1) { diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftService.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftService.kt index 56f6d3de..7402431f 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftService.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/network/PayCraftService.kt @@ -171,7 +171,19 @@ interface PayCraftService { * Default 0 so alternate [PayCraftService] fakes stay source-compatible, and because 0 is the * safe reading of "unknown" — it withholds a spend rather than granting one. */ - suspend fun balanceOf(subjectId: String, sku: String): Int = 0 + suspend fun balanceOf(serverToken: String, sku: String): Int = 0 + + /** + * Whether this device's owner already owns the one-time product [sku]. + * + * Distinct from [isPremium], which answers a SUBSCRIPTION question. A store needs the narrower + * one so it stops selling something already bought. + * + * Default false so alternate [PayCraftService] fakes stay source-compatible, and because false + * is the safe reading of "unknown" — it offers the product for sale rather than unlocking + * content nobody paid for. + */ + suspend fun owns(serverToken: String, sku: String): Boolean = false /** * Spend [quantity] of [sku], atomically. @@ -184,7 +196,15 @@ interface PayCraftService { * @return the new balance, or null when the server rejected it — insufficient funds is the * expected case and is NOT an exception (AR-012): a shopper short on coins is normal. */ - suspend fun spendBalance(subjectId: String, sku: String, quantity: Int, idempotencyKey: String): Int? = null + suspend fun spendBalance(serverToken: String, sku: String, quantity: Int, idempotencyKey: String): Int? = null + + /** + * Credit an in-app ACTIVITY earn; null when unreachable, or when the sku has no server-side + * `daily_earn_cap` (activity earning is opt-in per sku, because enabling it is what lets a + * modified client mint up to that ceiling). + */ + suspend fun earnBalance(serverToken: String, sku: String, quantity: Int, eventId: String): Int? = null + /** * Register a completed **Google Play** purchase server-side (Payments-policy native lane). @@ -286,16 +306,44 @@ class PayCraftServiceImpl(private val client: SupabaseClient, private val apiKey null } - override suspend fun balanceOf(subjectId: String, sku: String): Int = try { + override suspend fun owns(serverToken: String, sku: String): Boolean = try { + PayCraftLogger.onRpcCall("entitlement_owns", "sku=$sku") + val result = postgrest.rpc( + function = "entitlement_owns", + parameters = buildJsonObject { + // Same shape as the balance RPCs since 159/160: the subject is the SERVER's to + // derive from the device token, never the caller's to name. + put("p_server_token", serverToken) + put("p_api_key", apiKey) + put("p_sku", sku) + }, + ).data + val decoded = result.trim().equals("true", ignoreCase = true) + PayCraftLogger.onRpcResult("entitlement_owns", decoded.toString()) + decoded + } catch (e: Exception) { + // false on failure, NEVER optimistic. Play and StoreKit can both report success for a + // transaction that later fails verification, so ownership is only ever the server's word. + PayCraftLogger.onRpcError("entitlement_owns", e.message) + false + } + + override suspend fun balanceOf(serverToken: String, sku: String): Int = try { PayCraftLogger.onRpcCall("entitlement_balance_get", "sku=$sku") val result = postgrest.rpc( function = "entitlement_balance_get", parameters = buildJsonObject { - // The publishable key, NOT a tenant id. The server resolves the tenant from it - // (resolve_tenant, the house pattern since migration 016) so a client cannot name - // someone else's tenant and read their balances. + // NEITHER id is the caller's to supply. The tenant resolves from the publishable key + // (resolve_tenant, the house pattern since migration 016) and the SUBJECT resolves + // from the device token (migration 159, matching is_premium/get_subscription). + // + // This took p_subject_id until 159. The key ships in every client binary, so a + // caller-supplied subject let anyone holding it read — and via spend, DRAIN — + // another user's balance within the same tenant. It was also why this method had no + // callers: entitlement_subject_resolve is service_role-only, so a legitimate client + // could never learn its own subject id to pass here. + put("p_server_token", serverToken) put("p_api_key", apiKey) - put("p_subject_id", subjectId) put("p_sku", sku) }, ).data @@ -309,14 +357,44 @@ class PayCraftServiceImpl(private val client: SupabaseClient, private val apiKey 0 } - override suspend fun spendBalance(subjectId: String, sku: String, quantity: Int, idempotencyKey: String): Int? = + override suspend fun earnBalance(serverToken: String, sku: String, quantity: Int, eventId: String): Int? = + try { + PayCraftLogger.onRpcCall("entitlement_balance_earn", "sku=$sku qty=$quantity") + val result = postgrest.rpc( + function = "entitlement_balance_earn", + parameters = buildJsonObject { + // Token, not subject id — same reason as spend below. `eventId` IS the + // idempotency key: it identifies the ACTIVITY, so a replayed sync cannot pay + // twice. The server clamps to the tenant's daily cap, so the returned balance + // may be lower than balance+quantity; that is the cap, not a failure. + put("p_server_token", serverToken) + put("p_api_key", apiKey) + put("p_sku", sku) + put("p_quantity", quantity) + put("p_idempotency_key", eventId) + }, + ).data + val decoded = result.trim().toIntOrNull() + PayCraftLogger.onRpcResult("entitlement_balance_earn", decoded?.toString() ?: "rejected") + decoded + } catch (e: Exception) { + // Null covers unreachable AND the sku having no daily_earn_cap (the RPC raises). The + // caller queues for replay either way, which is correct for both. + PayCraftLogger.onError("entitlement_balance_earn", e.message ?: "earn failed") + null + } + + override suspend fun spendBalance(serverToken: String, sku: String, quantity: Int, idempotencyKey: String): Int? = try { PayCraftLogger.onRpcCall("entitlement_balance_spend", "sku=$sku qty=$quantity") val result = postgrest.rpc( function = "entitlement_balance_spend", parameters = buildJsonObject { + // Token, not subject id — see balanceOf above and migration 159. On a SPEND the + // old shape was worse than a leak: it let one holder of the publishable key + // drain another subject's balance. + put("p_server_token", serverToken) put("p_api_key", apiKey) - put("p_subject_id", subjectId) put("p_sku", sku) put("p_quantity", quantity) put("p_idempotency_key", idempotencyKey) diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftSettingsStore.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftSettingsStore.kt index 83267866..709f0719 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftSettingsStore.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftSettingsStore.kt @@ -111,6 +111,38 @@ class PayCraftSettingsStore(private val settings: Settings = Settings()) : PayCr override fun clearDeviceToken() = settings.remove(KEY_DEVICE_TOKEN) + // ── Balance outbox ─────────────────────────────────────────────────────────────────────────── + // + // One Settings key per queued write, prefixed by kind. A map rather than a single slot because + // several spends can be queued while offline, and collapsing them would lose all but the last. + // The VALUE carries sku + quantity; the KEY carries the idempotency key, which is the thing a + // replay must preserve. + + override fun enqueueSpend(key: String, sku: String, quantity: Int) = + settings.putString(KEY_OUTBOX_SPEND + key, "$sku|$quantity") + + override fun dequeueSpend(key: String) = settings.remove(KEY_OUTBOX_SPEND + key) + + override fun pendingSpends(): List = readOutbox(KEY_OUTBOX_SPEND) + + override fun enqueueEarn(eventId: String, sku: String, quantity: Int) = + settings.putString(KEY_OUTBOX_EARN + eventId, "$sku|$quantity") + + override fun dequeueEarn(eventId: String) = settings.remove(KEY_OUTBOX_EARN + eventId) + + override fun pendingEarns(): List = readOutbox(KEY_OUTBOX_EARN) + + private fun readOutbox(prefix: String): List = settings.keys + .asSequence() + .filter { it.startsWith(prefix) } + .mapNotNull { k -> + val raw = settings.getStringOrNull(k) ?: return@mapNotNull null + val parts = raw.split("|") + val qty = parts.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null + PendingBalanceWrite(id = k.removePrefix(prefix), sku = parts[0], quantity = qty) + } + .toList() + private fun adUnlockKey(featureKey: String) = KEY_AD_UNLOCK_PREFIX + featureKey companion object { @@ -124,5 +156,7 @@ class PayCraftSettingsStore(private val settings: Settings = Settings()) : PayCr private const val KEY_AD_UNLOCK_PREFIX = "paycraft_ad_unlock_" private const val KEY_AD_UNLOCK_INDEX = "paycraft_ad_unlock_idx_" private const val KEY_DEVICE_TOKEN = "paycraft_device_token" + private const val KEY_OUTBOX_SPEND = "paycraft_outbox_spend_" + private const val KEY_OUTBOX_EARN = "paycraft_outbox_earn_" } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftStore.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftStore.kt index 3af81511..e2c5aab0 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftStore.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/persistence/PayCraftStore.kt @@ -85,6 +85,41 @@ interface PayCraftStore { fun saveDeviceToken(token: String) {} fun clearDeviceToken() {} + // ─── Consumable drain intent ───────────────────────────────────────────── + // + // A purchased balance is moved to the host in two writes against different systems (debit the + // server, then credit the host), and a crash between them is the ordinary case. The intent is + // persisted BEFORE the server is touched so the next launch can replay it under the same id. + // Defaulted no-ops: a store that does not implement these simply loses crash-resume, it does + // not break — and every existing implementation keeps compiling. + + // ── Balance outbox ─────────────────────────────────────────────────────────────────────────── + // + // Writes made while offline, replayed on reconcile. Defaulted no-ops so an older PayCraftStore + // keeps compiling — but a store that does NOT persist these loses an offline spend or earn on + // process death, so a real implementation must override them. + // + // Keyed by the caller's idempotency key (a spend) or the host's event id (an earn), which is + // what makes a replay safe: the server absorbs a duplicate and returns the same balance. + + /** Record a spend that has not been acknowledged by the server. */ + fun enqueueSpend(key: String, sku: String, quantity: Int) {} + + /** Drop an acknowledged (or permanently refused) spend. */ + fun dequeueSpend(key: String) {} + + /** Spends still awaiting the server. */ + fun pendingSpends(): List = emptyList() + + /** Record an activity earn that has not reached the server. */ + fun enqueueEarn(eventId: String, sku: String, quantity: Int) {} + + /** Drop an acknowledged earn. */ + fun dequeueEarn(eventId: String) {} + + /** Earns still awaiting the server. */ + fun pendingEarns(): List = emptyList() + // ── Wipe ───────────────────────────────────────────────────────────────────────────────────── /** @@ -102,3 +137,11 @@ interface PayCraftStore { getAllAdUnlocks().keys.forEach { clearAdUnlock(it) } } } + +/** + * A balance write that has not been acknowledged by the server. + * + * [id] is the idempotency key (a spend) or the host's stable event id (an earn) — never generated + * at replay time, or a retry would be counted as a second write. + */ +public data class PendingBalanceWrite(val id: String, val sku: String, val quantity: Int) diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/FakePayCraftRepository.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/FakePayCraftRepository.kt index 65fdf327..aeb4fa7a 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/FakePayCraftRepository.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/FakePayCraftRepository.kt @@ -71,6 +71,10 @@ class FakePayCraftRepository( }, ), plans: List = emptyList(), + /** Starting consumable balances by sku. Mutable mid-run via [setBalance]. */ + balances: Map = emptyMap(), + /** Skus owned outright at construction. Mutable mid-run via [setOwned]. */ + ownedSkus: Set = emptySet(), /** * What [restore] returns. Null → [restore] throws, so the unhappy path is testable too. * @@ -87,6 +91,8 @@ class FakePayCraftRepository( private val _isInTrial = MutableStateFlow(inTrial) private val _plans = MutableStateFlow(plans) private val _entitlement = MutableStateFlow(entitlement) + private val _balances = MutableStateFlow(balances) + private val _ownedSkus = MutableStateFlow(ownedSkus) override val isPremium: StateFlow = _isPremium.asStateFlow() override val billingState: StateFlow = _billingState.asStateFlow() @@ -94,6 +100,8 @@ class FakePayCraftRepository( override val isInTrial: StateFlow = _isInTrial.asStateFlow() override val plans: StateFlow> = _plans.asStateFlow() override val entitlement: StateFlow = _entitlement.asStateFlow() + override val balances: StateFlow> = _balances.asStateFlow() + override val ownedSkus: StateFlow> = _ownedSkus.asStateFlow() /** Every [checkout] call, in order — assert a tap reached the SDK. */ val checkoutCalls: MutableList> = mutableListOf() @@ -275,4 +283,61 @@ class FakePayCraftRepository( // Releasing the slot drops the entitlement on THIS device, same reasoning as logOut. setPremium(false) } + + /** Every [spend] call, in order — assert quantity AND the idempotency key the host chose. */ + val spendCalls: MutableList> = mutableListOf() + + /** Seed or change a balance mid-run (e.g. simulate the webhook grant landing). */ + fun setBalance(sku: String, balance: Int) { + _balances.value = _balances.value + (sku to balance) + } + + /** Reads straight from the seeded map — a fake has no server to ask. Absent sku reads 0. */ + override suspend fun refreshBalance(sku: String): Int = _balances.value[sku] ?: 0 + + /** Seed or change ownership mid-run (e.g. simulate the purchase being verified, or refunded). */ + fun setOwned(sku: String, owned: Boolean) { + _ownedSkus.value = if (owned) _ownedSkus.value + sku else _ownedSkus.value - sku + } + + override suspend fun refreshOwnership(sku: String): Boolean = sku in _ownedSkus.value + + /** + * Mirrors the real contract closely enough to test the host against it: + * - insufficient funds is [Result.failure], never an exception (AR-012); + * - a REPLAYED idempotency key returns the current balance without debiting again (AR-011), + * so a host's retry path can be exercised without double-charging the fake. + */ + override suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result { + if (spendCalls.any { it.third == idempotencyKey }) { + return Result.success(_balances.value[sku] ?: 0) + } + spendCalls += Triple(sku, quantity, idempotencyKey) + val current = _balances.value[sku] ?: 0 + if (current < quantity) { + return Result.failure(IllegalStateException("insufficient_balance: have $current, need $quantity")) + } + val next = current - quantity + _balances.value = _balances.value + (sku to next) + return Result.success(next) + } + + /** Earn ids already honoured, so a replay is a no-op exactly as the server's key makes it. */ + val earnCalls: MutableList> = mutableListOf() + + /** Optional ceiling, mirroring the server's daily cap. Null = uncapped. */ + var dailyEarnCap: Int? = null + + override suspend fun earn(sku: String, quantity: Int, eventId: String): Int { + if (earnCalls.any { it.third == eventId }) return _balances.value[sku] ?: 0 + earnCalls += Triple(sku, quantity, eventId) + val current = _balances.value[sku] ?: 0 + // Clamp like the server: an honest client at its ceiling gets less than it asked for and + // no error, so a host that treats "granted < requested" as a failure is wrong here too. + val cap = dailyEarnCap + val granted = if (cap == null) quantity else minOf(quantity, maxOf(cap - current, 0)) + val next = current + granted + _balances.value = _balances.value + (sku to next) + return next + } } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepository.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepository.kt index f0c1d055..3923c5cf 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepository.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepository.kt @@ -97,6 +97,21 @@ interface PayCraftRepository { */ val plans: StateFlow> + /** + * Server-authoritative consumable balances by sku (coins, credits, gems). + * + * READ-ONLY with no local increment path (AR-013) — a client-writable balance is forgeable + * currency. An absent sku has not been read yet; that is NOT a zero balance. + */ + val balances: StateFlow> + + /** + * Skus owned outright (one-time unlocks). Server-confirmed only — a sku appears here when the + * server says so, never optimistically on a purchase return (AR: `owns` is false until the + * server confirms the grant). + */ + val ownedSkus: StateFlow> + /** * Start checkout. **Fire-and-observe**, matching `PayCraft.checkout`: it routes to the correct * lane (native store vs web) and returns immediately. @@ -107,6 +122,32 @@ interface PayCraftRepository { * Never opens a web checkout for a digital good on Android/iOS; the lane resolution owns that * and fails closed rather than committing an anti-steering violation. */ + /** Re-read [sku]'s balance from the server into [balances]; 0 when it cannot be read. */ + suspend fun refreshBalance(sku: String): Int + + /** Re-read ownership of [sku] into [ownedSkus]; false when it cannot be read. */ + suspend fun refreshOwnership(sku: String): Boolean + + /** + * Spend [quantity] of [sku], server-checked and atomic. + * + * [idempotencyKey] is REQUIRED (AR-011) so a retry after a timeout replays rather than charges + * twice. Insufficient funds is a [Result.failure], not an exception (AR-012). + */ + suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result + + /** + * Report an in-app ACTIVITY earn (a completed session, a streak) and return the new balance. + * + * [eventId] is the idempotency key: it must identify the activity, not the call, or a retry + * pays twice. The server CLAMPS to the tenant's daily cap, so the result may be lower than + * balance+quantity — an honest client at its ceiling, not an error. + * + * Activity earns are client-asserted by construction (no server can observe a focus session), + * which is exactly why the ceiling is enforced server-side. + */ + suspend fun earn(sku: String, quantity: Int, eventId: String): Int + fun checkout(plan: BillingPlan, email: String? = null) /** diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepositoryImpl.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepositoryImpl.kt index db86c92b..d4f81e5f 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepositoryImpl.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/repository/PayCraftRepositoryImpl.kt @@ -57,6 +57,8 @@ internal class PayCraftRepositoryImpl( ) : PayCraftRepository { override val isPremium: StateFlow get() = billing.isPremium + override val balances: StateFlow> get() = billing.balances + override val ownedSkus: StateFlow> get() = billing.ownedSkus override val billingState: StateFlow get() = billing.billingState override val subscriptionStatus: StateFlow get() = billing.subscriptionStatus override val isInTrial: StateFlow get() = billing.isInTrial @@ -164,4 +166,14 @@ internal class PayCraftRepositoryImpl( // confirmDeviceTransfer", so exposing it would hand a host the half-step that skips the // deactivation of the other device. override suspend fun revokeCurrentDevice() = billing.revokeCurrentDevice() + + override suspend fun refreshBalance(sku: String): Int = billing.refreshBalance(sku) + + override suspend fun refreshOwnership(sku: String): Boolean = billing.refreshOwnership(sku) + + override suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result = + billing.spend(sku, quantity, idempotencyKey) + + override suspend fun earn(sku: String, quantity: Int, eventId: String): Int = + billing.earn(sku, quantity, eventId) } diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftBalance.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftBalance.kt new file mode 100644 index 00000000..f1a348b5 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftBalance.kt @@ -0,0 +1,59 @@ +package com.mobilebytelabs.paycraft.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mobilebytelabs.paycraft.core.PayCraftBalances +import org.koin.compose.koinInject + +/** + * A server-authoritative consumable balance for one [sku] (API_CONTRACTS.md "Product catalogue & + * balances"). + * + * Reads [PayCraftRepository.balances] and refreshes [sku] on first composition. It renders NOTHING + * until the server has answered — a sku absent from the map has not been read yet, and that is a + * different thing from a zero balance. Showing "0" during the read would tell a user who just paid + * that their coins are gone, which is the one message this surface must never produce by accident. + * + * Takes no purchase callback and performs no purchase (AR-014): it reports a number, nothing more. + * Hosts that want their own presentation (cappy renders "⚡ 550" in its own shop chrome) should read + * `repository.balances` directly rather than restyling this. + */ +@Composable +public fun PayCraftBalance(sku: String, modifier: Modifier = Modifier) { + // The SDK-owned mirror, not the repository's raw flow: PayCraftBalances is what reconciles the + // outbox, so reading past it would show a number that ignores offline spends still queued. + val balanceStore: PayCraftBalances = koinInject() + val balances by balanceStore.balances.collectAsStateWithLifecycle() + + // Keyed on sku so switching products re-reads rather than showing the previous product's number. + LaunchedEffect(sku) { balanceStore.reconcile(sku) } + + val balance = balances[sku] ?: return + + Surface( + modifier = modifier.testTag(PayCraftTestTags.BALANCE), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = MaterialTheme.shapes.large, + ) { + Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) { + Text( + text = balance.toString(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.semantics { contentDescription = "Balance: $balance" }, + ) + } + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftEarnSection.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftEarnSection.kt new file mode 100644 index 00000000..b0438292 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftEarnSection.kt @@ -0,0 +1,74 @@ +package com.mobilebytelabs.paycraft.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.mobilebytelabs.paycraft.core.PayCraftAdRewards +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +/** + * Watch-to-earn, as one bindable surface. + * + * Renders NOTHING when the host bound no `adProvider` — an app that sells no ads gets no dead + * button, and a host does not have to branch on availability itself. That is the same contract the + * gates use: the surface decides whether it applies. + * + * The balance it shows afterwards is the RECONCILED one, not a locally added reward. The ad + * network's signed callback decides the amount server-side, so a client-computed delta would be a + * number the server overrules a moment later. + */ +@Composable +public fun PayCraftEarnSection( + sku: String, + modifier: Modifier = Modifier, + title: String = "Watch an ad to earn", + buttonLabel: String = "Watch", +) { + val ads: PayCraftAdRewards = koinInject() + if (!ads.isAvailable) return + + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Column( + modifier = modifier.fillMaxWidth().testTag(PayCraftTestTags.EARN_SECTION), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.bodyMedium) + Button( + onClick = { + // Guarded rather than disabled-only: a double tap mid-flight would show two ads and + // credit one, which reads to the user as a reward that went missing. + if (busy) return@Button + busy = true + scope.launch { + try { + ads.watchAndEarn(sku) + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.testTag(PayCraftTestTags.EARN_BUTTON), + ) { + if (busy) CircularProgressIndicator(modifier = Modifier.size(16.dp)) + else Text(buttonLabel) + } + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPaywallViewModel.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPaywallViewModel.kt index a5e05107..1ab1a2fa 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPaywallViewModel.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPaywallViewModel.kt @@ -75,7 +75,10 @@ internal class PayCraftPaywallViewModel(private val billingManager: BillingManag val config = PayCraft.config ?: return val providers = PayCraft.suiteConfig?.providers ?: emptyList() _state.update { current -> - val plans = config.plans + // The paywall sells PLANS. config.plans is the whole catalogue now that consumables + // reach it, so a coin pack would appear in the plan grid beside the subscriptions — + // which is exactly what PayCraftStoreSection exists to keep separate. + val plans = config.plans.filterNot { it.isConsumable } val selection = if (userPickedPlan) { // Preserve the user's pick (re-fetched from the refreshed list so the // BillingPlan instance stays current); fall back if it vanished. diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanBanner.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanBanner.kt index 0eed9606..71335d13 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanBanner.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanBanner.kt @@ -198,7 +198,9 @@ fun PayCraftPlanBanner( val isLoading = billingState is BillingState.Loading && lastStableState is BillingState.Premium val benefits = PayCraft.requireConfig().benefits - val plans = PayCraft.requireConfig().plans + // subscriptionPlans, not plans: this picks a TOP TIER by rank, and an unfiltered pick can + // land on a consumable pack now that the catalogue carries them (PayCraft.subscriptionPlans). + val plans = PayCraft.subscriptionPlans val supportEmail = PayCraft.requireConfig().supportEmail // Live dashboard config — drives the free card's brand color + copy reactively, so a // dashboard edit recolors/recopies the Settings banner without a cold relaunch. diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanSection.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanSection.kt index d4b0f623..a73c3e37 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanSection.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftPlanSection.kt @@ -192,9 +192,13 @@ internal fun rememberSubscriptionSettingsState( // The SAME StateFlow the paywall renders from. Deriving a second price here — off ProductDto's // base_price_cents, say — would quote the catalogue price while the paywall quotes the native // store's, and the two disagree exactly where it matters (a Play price differs by region/offer). + // Consumables excluded: the FREE-tier branch below takes minByOrNull { rank }, which would + // otherwise offer a coin pack as the upgrade. Collected (not PayCraft.subscriptionPlans) so a + // dashboard edit still reaches an open screen. val plans by repo.plans.collectAsState() + val sellablePlans = plans.filterNot { it.isConsumable } - val priced = planLineFor(entitlement.tier, entitlement.planSku, plans, copy) + val priced = planLineFor(entitlement.tier, entitlement.planSku, sellablePlans, copy) return PlanSectionState( visible = configured, diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftProductGate.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftProductGate.kt new file mode 100644 index 00000000..c62181ff --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftProductGate.kt @@ -0,0 +1,50 @@ +package com.mobilebytelabs.paycraft.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mobilebytelabs.paycraft.repository.PayCraftRepository +import org.koin.compose.koinInject + +/** + * Shows [content] only when the user owns the one-time product [sku]. + * + * This is the ONE-TIME-UNLOCK counterpart to the subscription gates in `PayCraftGates.kt`: + * `isPremium` answers "entitled to anything premium", this answers "owns THIS sku". A store that + * only knew the former would keep selling a lifetime unlock to someone who had already bought it. + * + * ## Closed until the server says otherwise + * + * Ownership starts false and only opens once [PayCraftRepository.refreshOwnership] returns true. + * It deliberately does NOT open optimistically when a purchase flow returns success: Play and + * StoreKit can both report success for a transaction that later fails verification, and a gate + * that trusted the client would hand over paid content for a purchase that never settled. + * + * The inverse matters as much — the refresh REMOVES a sku on a false, so a refund or a failed + * re-verification closes a gate an earlier purchase opened. A gate that only ever opened would + * stay open forever after the first true. + * + * Renders nothing while unowned — no placeholder, no upsell. What to show a non-owner is a product + * decision (a locked row, a store section, nothing at all), so the host composes that around this + * rather than inheriting a default it then has to fight. Takes no purchase callback (AR-014). + */ +@Composable +public fun PayCraftProductGate(sku: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val repository: PayCraftRepository = koinInject() + val owned by repository.ownedSkus.collectAsStateWithLifecycle() + + // Keyed on sku so a changed product re-reads rather than reusing the previous verdict. + LaunchedEffect(sku) { repository.refreshOwnership(sku) } + + if (sku in owned) { + Box( + modifier = modifier.testTag(PayCraftTestTags.PRODUCT_GATE_UNLOCKED), + ) { + content() + } + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftStoreSection.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftStoreSection.kt new file mode 100644 index 00000000..e08acba3 --- /dev/null +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftStoreSection.kt @@ -0,0 +1,114 @@ +package com.mobilebytelabs.paycraft.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.mobilebytelabs.paycraft.model.BillingPlan +import com.mobilebytelabs.paycraft.repository.PayCraftRepository +import org.koin.compose.koinInject + +/** + * The non-subscription store — consumable packs and one-time unlocks, each buyable in one tap. + * + * ## What it deliberately does not do + * + * It takes **no purchase callback** (AR-014). On 2026-10-19 a host-callback surface shipped with a + * dead upgrade row: it reported `clickable=true`, its handler ran, its golden matched and its logic + * tests passed — and only an on-device render caught that nothing happened. A surface that owns its + * own presentation cannot fail that way, so this calls [PayCraftRepository.checkout] directly. + * + * It also lists only the non-subscription catalogue — subscriptions and trials are excluded, because a store + * that offers a plan beside a coin pack invites a user to buy a subscription they may already hold. + * + * Balances are NOT shown per row. A pack's balance is the balance of the currency it grants, not of + * the pack sku, and only the host knows how it wants to present its own currency. Hosts render + * [PayCraftBalance] (or read `repository.balances`) wherever that belongs in their chrome. + */ +@Composable +public fun PayCraftStoreSection(modifier: Modifier = Modifier) { + val repository: PayCraftRepository = koinInject() + // Collected rather than read once: a dashboard edit (new pack, changed price) reaches an open + // store without a cold relaunch, and the paywall reads the SAME flow, so the two surfaces + // cannot disagree about a price. + val plans by repository.plans.collectAsStateWithLifecycle() + val products = plans.filter { it.isConsumable || it.interval == "lifetime" } + + Column( + modifier = modifier.fillMaxWidth().testTag(PayCraftTestTags.STORE_SECTION), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (products.isEmpty()) { + // An empty catalogue is a configuration state, not an error: a tenant may sell only + // subscriptions. Say so plainly rather than rendering an empty box the host cannot + // distinguish from a failed load. + Text( + text = "No products available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(PayCraftTestTags.STORE_EMPTY), + ) + return@Column + } + + products.forEach { product -> + StoreProductRow( + product = product, + onBuy = { repository.checkout(product) }, + ) + } + } +} + +/** + * One purchasable row — name, what it grants, price. + * + * The grant line renders only for a consumable with a known quantity. A pack whose + * [BillingPlan.grantQuantity] is 0 is misconfigured server-side, and printing "0" would read as a + * deliberate offer of nothing; omitting the line makes the gap visible to whoever configured it + * without lying to the buyer. + */ +@Composable +private fun StoreProductRow(product: BillingPlan, onBuy: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .testTag(PayCraftTestTags.STORE_PRODUCT_ROW) + .clickable(onClick = onBuy), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = product.name, style = MaterialTheme.typography.titleSmall) + if (product.isConsumable && product.grantQuantity > 0) { + Text( + text = "Grants ${product.grantQuantity}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text(text = product.price, style = MaterialTheme.typography.titleSmall) + } + } +} diff --git a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftTestTags.kt b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftTestTags.kt index 3483f5e6..22d3dc77 100644 --- a/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftTestTags.kt +++ b/cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/ui/PayCraftTestTags.kt @@ -198,4 +198,13 @@ object PayCraftTestTags { const val OFFLINE_MESSAGE = "paycraft_offline_message" const val STALE_MESSAGE = "paycraft_stale_message" const val STALE_REFRESH = "paycraft_stale_refresh" + + /** Consumable balances + the non-subscription store (coin packs, one-time unlocks). */ + const val BALANCE = "paycraft_balance" + const val STORE_SECTION = "paycraft_store_section" + const val EARN_SECTION = "paycraft_earn_section" + const val EARN_BUTTON = "paycraft_earn_button" + const val STORE_PRODUCT_ROW = "paycraft_store_product_row" + const val STORE_EMPTY = "paycraft_store_empty" + const val PRODUCT_GATE_UNLOCKED = "paycraft_product_gate_unlocked" } diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/config/ConsumableCatalogueMappingTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/config/ConsumableCatalogueMappingTest.kt new file mode 100644 index 00000000..bbd9daad --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/config/ConsumableCatalogueMappingTest.kt @@ -0,0 +1,142 @@ +package com.mobilebytelabs.paycraft.config + +import com.mobilebytelabs.paycraft.PayCraftBackend +import com.mobilebytelabs.paycraft.toPayCraftConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `SuiteConfig.toPayCraftConfig()` must carry CONSUMABLES through to `BillingPlan`. + * + * ## The bug this exists to prevent, and why nothing caught it + * + * The mapper opened with `filter { type == "subscription" || type == "lifetime" }` and then, 60 + * lines later, set `isConsumable = dto.type == "consumable"` — a line that could never be true, + * because the only rows reaching it had already been filtered to two types that are not it. So the + * flag was permanently false for every product in every app, and three unrelated features failed + * in ways that each looked like their own bug: + * + * - `PayCraftStoreSection` rendered "No products available" for a tenant with three live packs; + * - `ConsumableDrainer` filters `plans.filter { it.isConsumable }`, matched nothing, and so never + * moved a purchased balance into the host — money taken, units never delivered; + * - `PayCraftBillingManager.isConsumableStoreProduct` returned false, so a pack purchase was + * ACKNOWLEDGED instead of CONSUMED. Play then treats it as owned forever and refuses to sell it + * again. That one is not even recoverable by a later fix for the user it happened to. + * + * **Every existing test still passed**, and that is the part worth keeping in mind. `TrialConfigTest` + * proves `ProductDto` parses `type` correctly — it does, the DTO was never wrong. `StoreCatalogueTest` + * proves the `isConsumable || interval == "lifetime"` filter selects packs — it does, given plans + * that have the flag set. Both tested a side of the mapper without testing THROUGH it, so the one + * step that dropped the row was the one step nothing exercised. + * + * Hence these assertions go through the real `toPayCraftConfig` (it is `internal`, so commonTest + * reaches it) rather than re-deriving the mapping. A test that rebuilt the filter would agree with + * whatever the filter happens to be, including a wrong one. + */ +class ConsumableCatalogueMappingTest { + + private fun product( + sku: String, + type: String, + interval: String? = null, + grant: Int = 0, + order: Int = 0, + ) = ProductDto( + id = "id_$sku", + sku = sku, + type = type, + displayName = sku, + interval = interval, + basePriceCents = 199, + baseCurrency = "USD", + grantQuantity = grant, + displayOrder = order, + ) + + private fun configOf(vararg products: ProductDto) = SuiteConfig( + tenantId = "t", + products = products.toList(), + ) + + /** + * Run the REAL mapper. Mock backend because the catalogue mapping has no network in it — a + * Cloud backend would make this test depend on the SDK's Supabase constants to assert a pure + * list transform. + */ + private fun SuiteConfig.mapped() = + toPayCraftConfig(backend = PayCraftBackend.Mock(staticConfig = this), apiKey = "pk_test_mapping") + + @Test + fun a_consumable_survives_the_mapper_and_carries_its_flag_and_grant() { + val plans = configOf( + product("cappy_plus_monthly", type = "subscription", interval = "month", order = 0), + product("cappy_ripples_300", type = "consumable", grant = 300, order = 1), + ).mapped().plans + + assertEquals(2, plans.size, "a consumable must not be filtered out of the catalogue") + val pack = plans.single { it.sku == "cappy_ripples_300" } + assertTrue(pack.isConsumable, "isConsumable must be true for type=consumable") + assertEquals(300, pack.grantQuantity) + } + + @Test + fun a_one_time_product_also_survives() { + // `one_time` was dropped by the same allowlist. It is not a consumable — it must reach the + // catalogue with the flag OFF, or the drainer would try to debit a balance it never grants. + val plans = configOf(product("lifetime_unlock", type = "one_time")).mapped().plans + assertEquals(1, plans.size) + assertEquals(false, plans.single().isConsumable) + } + + @Test + fun a_subscription_is_never_marked_consumable() { + // The inverse error is worse than the original: a subscription finished with consumeAsync + // would be re-sellable to someone who already pays. + val plans = configOf( + product("monthly", type = "subscription", interval = "month"), + product("lifetime", type = "lifetime"), + ).mapped().plans + assertTrue(plans.none { it.isConsumable }) + } + + @Test + fun a_trial_is_still_excluded_from_the_catalogue() { + // The one deliberate exclusion. A trial is not independently sellable — it attaches to a + // parent product and is folded in as trialDays. Widening the filter must not resurrect it + // as a purchasable row. + val plans = configOf( + product("monthly", type = "subscription", interval = "month"), + product("monthly_trial", type = "trial"), + ).mapped().plans + assertEquals(listOf("monthly"), plans.map { it.sku }) + } + + @Test + fun an_unknown_future_type_is_kept_rather_than_silently_dropped() { + // This is the actual regression guard. The defect was not "consumable was missing from a + // list" — it was that the list was an ALLOWLIST, so any type the server learned and the SDK + // did not vanished without a word, far from where the symptom appeared. A denylist fails + // loudly instead: an unknown type shows up in the catalogue, where someone notices it. + val plans = configOf(product("mystery", type = "something_invented_next_quarter")) + .mapped().plans + assertEquals(1, plans.size, "an unrecognised product type must not be silently dropped") + } + + @Test + fun the_store_subset_and_the_paywall_subset_partition_the_catalogue() { + // Store and paywall read the SAME plans list and each take a half. Proven together because + // the failure mode is a product that falls in BOTH (a pack offered as an upgrade) or in + // NEITHER (bought, undeliverable) — and either way one list alone looks correct. + val config = configOf( + product("monthly", type = "subscription", interval = "month", order = 0), + product("ripples_300", type = "consumable", grant = 300, order = 1), + ).mapped() + + val store = config.plans.filter { it.isConsumable || it.interval == "lifetime" } + val paywall = config.plans.filterNot { it.isConsumable } + + assertEquals(listOf("ripples_300"), store.map { it.sku }) + assertEquals(listOf("monthly"), paywall.map { it.sku }) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/BalanceSpendTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/BalanceSpendTest.kt new file mode 100644 index 00000000..4cdee335 --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/BalanceSpendTest.kt @@ -0,0 +1,238 @@ +package com.mobilebytelabs.paycraft.core + +import com.mobilebytelabs.paycraft.model.OAuthProvider +import com.mobilebytelabs.paycraft.network.PayCraftService +import com.mobilebytelabs.paycraft.network.PremiumCheckResult +import com.mobilebytelabs.paycraft.network.RegisterDeviceResult +import com.mobilebytelabs.paycraft.network.SubscriptionDto +import com.mobilebytelabs.paycraft.persistence.PayCraftStore +import com.mobilebytelabs.paycraft.platform.DeviceTokenStore +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Consumable balances on [PayCraftBillingManager] — the layer that turns a bought coin pack into a + * spendable balance (REQUIREMENTS.md PC-001..006, API_CONTRACTS.md AR-011..AR-013). + * + * ## Why these assertions and not "the number went up" + * + * The balance is the one piece of SDK state a user pays real money for, so the invariants that + * matter are about what must NEVER happen: + * + * - **AR-013, no local write path.** Every published value must come from a server answer. A test + * that only checks "balance decreased after spend" passes just as happily against a local + * decrement, which is forgeable currency. So the fake returns a value the client could not have + * computed (a deliberate mismatch) and the test asserts the SERVER's number wins. + * - **AR-012, insufficient funds is not an exception.** A shopper short on coins is an ordinary + * outcome; the host renders a message, not a crash. + * - **A rejected spend must not move the balance.** Treating a failure as a debit — or worse, as + * a success — is the outcome that hands over goods nobody paid for. + * - **AR-011, the caller's idempotency key reaches the wire verbatim.** The key exists so a retry + * after a timeout replays instead of charging twice; a manager that regenerated or dropped it + * would silently reintroduce the double-charge it prevents. + * + * The subject is deliberately NOT a parameter anywhere here: migration 159 made the server derive + * it from the device token, which is why these calls take a token and why they could not be made + * honestly before (the client has no way to learn its own subject id). + */ +class BalanceSpendTest { + + private companion object { + const val SKU = "cappy_ripples_550" + const val TOKEN = "device-token-abc" + } + + /** Records what reached the wire and returns scripted answers. */ + private class FakeBalanceService( + private val balance: Int = 0, + /** What `spendBalance` returns; null models BOTH insufficient funds and transport failure. */ + private val spendResult: Int? = null, + ) : PayCraftService { + var balanceOfToken: String? = null + var spendToken: String? = null + var spendIdempotencyKey: String? = null + var spendQuantity: Int? = null + var spendCallCount = 0 + + override suspend fun isPremium(serverToken: String): Boolean = false + override suspend fun getSubscription(serverToken: String): SubscriptionDto? = null + override suspend fun isTrialEligible(serverToken: String): Boolean = false + override suspend fun checkPremiumWithDevice(serverToken: String): PremiumCheckResult = + PremiumCheckResult(isPremium = false, tokenValid = true) + override suspend fun transferToDevice(serverToken: String, newDeviceToken: String): Boolean = false + override suspend fun revokeDevice(serverToken: String, targetToken: String): Boolean = false + override suspend fun verifyOAuthToken(provider: OAuthProvider, idToken: String): String? = null + override suspend fun registerDevice( + email: String, + platform: String, + deviceName: String, + deviceId: String, + mode: String, + ): RegisterDeviceResult = RegisterDeviceResult(TOKEN, false, null, null) + + var ownsResult: Boolean = false + var ownsToken: String? = null + var ownsCallCount = 0 + + override suspend fun owns(serverToken: String, sku: String): Boolean { + ownsCallCount++ + ownsToken = serverToken + return ownsResult + } + + override suspend fun balanceOf(serverToken: String, sku: String): Int { + balanceOfToken = serverToken + return balance + } + + override suspend fun spendBalance( + serverToken: String, + sku: String, + quantity: Int, + idempotencyKey: String, + ): Int? { + spendCallCount++ + spendToken = serverToken + spendQuantity = quantity + spendIdempotencyKey = idempotencyKey + return spendResult + } + } + + /** Everything else on PayCraftStore has an interface default; only these three are abstract. */ + private class NoCacheStore : PayCraftStore { + override suspend fun saveEmail(email: String) = Unit + override suspend fun getEmail(): String? = null + override suspend fun clearEmail() = Unit + } + + private fun manager(service: PayCraftService) = PayCraftBillingManager(service = service, store = NoCacheStore()) + + @BeforeTest + fun seedDevice() = DeviceTokenStore.saveToken(TOKEN) + + @AfterTest + fun clearDevice() = DeviceTokenStore.clearToken() + + @Test + fun refreshBalance_publishes_the_server_value_and_sends_the_device_token() = runTest { + val service = FakeBalanceService(balance = 550) + val m = manager(service) + + assertEquals(550, m.refreshBalance(SKU)) + assertEquals(550, m.balances.value[SKU]) + // The token, never a subject id — the server resolves the subject (migration 159). + assertEquals(TOKEN, service.balanceOfToken) + } + + @Test + fun refreshBalance_without_a_registered_device_is_zero_and_does_not_pollute_the_map() = runTest { + DeviceTokenStore.clearToken() + val service = FakeBalanceService(balance = 999) + val m = manager(service) + + assertEquals(0, m.refreshBalance(SKU)) + // Crucially ABSENT, not 0. A published 0 is indistinguishable from a real empty wallet and + // would make a host render "you have none" for a user who simply is not signed in yet. + assertNull(m.balances.value[SKU]) + assertNull(service.balanceOfToken) // never reached the wire + } + + @Test + fun spend_publishes_the_servers_new_balance_not_a_locally_computed_one() = runTest { + // 550 - 120 would be 430 if the client did the arithmetic. The server says 999, which the + // client could not have derived — so if 999 is published, the server is the writer (AR-013). + val service = FakeBalanceService(balance = 550, spendResult = 999) + val m = manager(service) + m.refreshBalance(SKU) + + val result = m.spend(SKU, quantity = 120, idempotencyKey = "spend-key-1") + + assertEquals(999, result.getOrNull()) + assertEquals(999, m.balances.value[SKU]) + } + + @Test + fun a_rejected_spend_is_a_failure_and_leaves_the_balance_untouched() = runTest { + val service = FakeBalanceService(balance = 50, spendResult = null) + val m = manager(service) + m.refreshBalance(SKU) + + val result = m.spend(SKU, quantity = 120, idempotencyKey = "spend-key-2") + + assertTrue(result.isFailure, "insufficient funds must be Result.failure, not an exception (AR-012)") + // Unchanged: a rejected spend that still debited would lose currency the user still owns, + // and one that "succeeded" would hand over goods nobody paid for. + assertEquals(50, m.balances.value[SKU]) + } + + @Test + fun the_callers_idempotency_key_reaches_the_wire_verbatim() = runTest { + val service = FakeBalanceService(balance = 550, spendResult = 430) + val m = manager(service) + + m.spend(SKU, quantity = 120, idempotencyKey = "caller-owned-key") + + // Regenerating or dropping the key would make every retry a fresh charge — the precise + // failure the key exists to prevent (AR-011). + assertEquals("caller-owned-key", service.spendIdempotencyKey) + assertEquals(120, service.spendQuantity) + assertEquals(TOKEN, service.spendToken) + assertEquals(1, service.spendCallCount) + } + + @Test + fun spend_without_a_registered_device_fails_before_reaching_the_wire() = runTest { + DeviceTokenStore.clearToken() + val service = FakeBalanceService(balance = 550, spendResult = 430) + val m = manager(service) + + val result = m.spend(SKU, quantity = 120, idempotencyKey = "spend-key-3") + + assertTrue(result.isFailure) + assertEquals(0, service.spendCallCount, "a spend with no subject must never reach the server") + } + + @Test + fun ownership_is_server_confirmed_and_sends_the_device_token() = runTest { + val service = FakeBalanceService().apply { ownsResult = true } + val m = manager(service) + + assertTrue(m.refreshOwnership("remove_ads")) + assertTrue("remove_ads" in m.ownedSkus.value) + assertEquals(TOKEN, service.ownsToken) + } + + @Test + fun a_false_REMOVES_a_previously_owned_sku_so_a_refund_can_close_the_gate() = runTest { + val service = FakeBalanceService().apply { ownsResult = true } + val m = manager(service) + m.refreshOwnership("remove_ads") + assertTrue("remove_ads" in m.ownedSkus.value) + + // Refunded, or re-verification failed. + service.ownsResult = false + assertFalse(m.refreshOwnership("remove_ads")) + + // An add-only set would leave paid content unlocked forever after the first true — the + // whole reason refreshOwnership removes rather than only adds. + assertFalse("remove_ads" in m.ownedSkus.value) + } + + @Test + fun ownership_without_a_registered_device_is_false_and_never_reaches_the_wire() = runTest { + DeviceTokenStore.clearToken() + val service = FakeBalanceService().apply { ownsResult = true } + val m = manager(service) + + assertFalse(m.refreshOwnership("remove_ads")) + assertEquals(0, service.ownsCallCount) + assertTrue(m.ownedSkus.value.isEmpty()) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalancesTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalancesTest.kt new file mode 100644 index 00000000..c3fb7136 --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/PayCraftBalancesTest.kt @@ -0,0 +1,170 @@ +package com.mobilebytelabs.paycraft.core + +import com.mobilebytelabs.paycraft.model.BillingPlan +import com.mobilebytelabs.paycraft.persistence.PayCraftSettingsStore +import com.mobilebytelabs.paycraft.repository.FakePayCraftRepository +import com.russhwolf.settings.MapSettings +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [PayCraftBalances] — the SDK-owned balance mirror and its outbox. + * + * ## What is actually at risk + * + * This class exists so a host never keeps its own ledger. That makes it the only thing standing + * between "the server's number" and "the number the user sees", and the failures that matter are + * the ones where those two silently disagree: + * + * - **Never lose a write.** A spend or earn made offline must reach the server eventually. Dropping + * one means the user spent units that come back, or earned units that vanish. + * - **Never pay twice.** A replayed write must be absorbed. The outbox key IS the idempotency key + * for exactly this reason, and it is generated once at enqueue — never at replay. + * - **The server always wins.** The mirror is a cache. Every reconcile adopts the server's number + * including downward, because a client that could decide its own balance is minting currency. + * + * `assertEquals` on a RECORDED CALL rather than on the balance, wherever the distinction exists: a + * double-spend and a correct spend can land on the same number, and only the call list separates + * them. + */ +class PayCraftBalancesTest { + + private companion object { + const val SKU = "coins" + } + + private fun consumable(sku: String = SKU) = BillingPlan( + id = sku, name = sku, price = "$1.99", interval = "lifetime", rank = 0, + isConsumable = true, grantQuantity = 300, + ) + + private fun balances( + repo: FakePayCraftRepository, + store: PayCraftSettingsStore = PayCraftSettingsStore(MapSettings()), + key: String = "key-1", + ) = PayCraftBalances(repo, store, newIdempotencyKey = { key }) + + private fun repoWith(balance: Int) = FakePayCraftRepository().apply { + setPlans(listOf(consumable())) + setBalance(SKU, balance) + } + + @Test + fun reconcile_adopts_the_server_number() = runTest { + val repo = repoWith(500) + val b = balances(repo) + b.reconcile(SKU) + assertEquals(500, b.balanceOf(SKU)) + } + + @Test + fun the_server_wins_even_when_it_lowers_the_balance() = runTest { + // The direction a cache is tempted to ignore. A refund, a correction, or another device's + // spend all arrive as "the server says less than you think" — and the mirror must take it. + val repo = repoWith(500) + val b = balances(repo) + b.reconcile(SKU) + assertEquals(500, b.balanceOf(SKU)) + + repo.setBalance(SKU, 120) + b.reconcile(SKU) + assertEquals(120, b.balanceOf(SKU), "the mirror must never hold above the server") + } + + @Test + fun a_spend_applies_immediately_and_clears_its_outbox_row() = runTest { + val repo = repoWith(500) + val store = PayCraftSettingsStore(MapSettings()) + val b = balances(repo, store) + b.reconcile(SKU) + + assertTrue(b.spend(SKU, 200)) + assertEquals(300, b.balanceOf(SKU)) + assertEquals(1, repo.spendCalls.size) + assertTrue(store.pendingSpends().isEmpty(), "an acknowledged spend must leave no outbox row") + } + + @Test + fun a_spend_beyond_the_local_balance_is_refused_without_touching_the_server() = runTest { + val repo = repoWith(50) + val b = balances(repo) + b.reconcile(SKU) + + assertFalse(b.spend(SKU, 200)) + assertEquals(50, b.balanceOf(SKU)) + assertTrue(repo.spendCalls.isEmpty(), "an obviously-unaffordable spend must not hit the wire") + } + + @Test + fun an_earn_is_idempotent_on_its_event_id() = runTest { + // The sync case: the same focus session reported twice must pay once. eventId identifies the + // ACTIVITY, so this is the property that makes a retried sync safe. + val repo = repoWith(0) + val b = balances(repo) + + val first = b.earn(SKU, 25, "focus-session-42") + val second = b.earn(SKU, 25, "focus-session-42") + + assertEquals(25, first) + assertEquals(25, second, "a replayed earn must not pay twice") + assertEquals(1, repo.earnCalls.size) + } + + @Test + fun an_earn_is_clamped_by_the_server_cap_without_erroring() = runTest { + // The server clamps to daily_earn_cap. A host that treated "granted < requested" as a + // failure would show an error to an honest user who simply reached their ceiling. + val repo = repoWith(0).apply { dailyEarnCap = 50 } + val b = balances(repo) + + assertEquals(40, b.earn(SKU, 40, "evt-a")) + assertEquals(50, b.earn(SKU, 40, "evt-b"), "clamped to the cap, not rejected") + assertEquals(50, b.earn(SKU, 40, "evt-c"), "already at the ceiling — still no error") + } + + @Test + fun reconcile_replays_queued_writes_before_reading() = runTest { + // Order is load-bearing: reading first would return a balance that does not yet reflect the + // queued writes, and the mirror would visibly bounce. + val repo = repoWith(500) + val store = PayCraftSettingsStore(MapSettings()) + store.enqueueEarn("evt-offline", SKU, 25) + store.enqueueSpend("spend-offline", SKU, 100) + + balances(repo, store).reconcile(SKU) + + assertEquals(1, repo.earnCalls.size, "the queued earn must be replayed") + assertEquals(1, repo.spendCalls.size, "the queued spend must be replayed") + assertTrue(store.pendingEarns().isEmpty()) + assertTrue(store.pendingSpends().isEmpty()) + } + + @Test + fun the_outbox_survives_a_new_store_instance() = runTest { + // Process death. The whole point of persisting rather than holding them in memory: a write + // made offline must still be there after a restart. + val settings = MapSettings() + PayCraftSettingsStore(settings).enqueueSpend("k-1", SKU, 100) + + val revived = PayCraftSettingsStore(settings).pendingSpends() + assertEquals(1, revived.size) + assertEquals("k-1", revived.single().id) + assertEquals(100, revived.single().quantity) + } + + @Test + fun several_offline_spends_are_all_kept() = runTest { + // A single-slot design would keep only the last. Each spend is its own idempotency key and + // its own ledger row, so all of them have to survive. + val store = PayCraftSettingsStore(MapSettings()) + store.enqueueSpend("k-1", SKU, 10) + store.enqueueSpend("k-2", SKU, 20) + store.enqueueSpend("k-3", SKU, 30) + + assertEquals(3, store.pendingSpends().size) + assertEquals(60, store.pendingSpends().sumOf { it.quantity }) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/StoreCatalogueTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/StoreCatalogueTest.kt new file mode 100644 index 00000000..a70ef837 --- /dev/null +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/core/StoreCatalogueTest.kt @@ -0,0 +1,90 @@ +package com.mobilebytelabs.paycraft.core + +import com.mobilebytelabs.paycraft.model.BillingPlan +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The non-subscription catalogue that [com.mobilebytelabs.paycraft.ui.PayCraftStoreSection] renders. + * + * The filter is one line, but it is the line that decides what a user is offered to BUY, and both + * ways of getting it wrong are bad in a way a reviewer will not notice: + * + * - letting a **subscription** through puts a plan beside a coin pack, inviting a user to "buy" a + * subscription they may already hold, through a flow that does not handle that; + * - dropping a **lifetime unlock** silently removes a product the tenant is selling, and an empty + * store looks identical to a tenant that simply has no products. + * + * `interval` cannot distinguish these alone — the `/config` mapper collapses a null interval to the + * literal "lifetime", so a consumable and a lifetime unlock look the same by interval. That is + * exactly why `isConsumable` is carried explicitly on [BillingPlan], and why this test pins both. + */ +class StoreCatalogueTest { + + private fun plan(id: String, interval: String, consumable: Boolean = false, grant: Int = 0) = BillingPlan( + id = id, + name = id, + price = "$0.99", + interval = interval, + rank = 0, + isConsumable = consumable, + grantQuantity = grant, + ) + + /** Mirrors the filter in PayCraftStoreSection / PayCraft.products. */ + private fun products(all: List) = all.filter { it.isConsumable || it.interval == "lifetime" } + + @Test + fun subscriptions_and_trials_are_excluded_from_the_store() { + val all = listOf( + plan("monthly", interval = "month"), + plan("yearly", interval = "year"), + plan("coins_550", interval = "lifetime", consumable = true, grant = 550), + ) + + val store = products(all) + + assertEquals(listOf("coins_550"), store.map { it.id }) + assertFalse(store.any { it.interval == "month" || it.interval == "year" }) + } + + @Test + fun one_time_lifetime_unlocks_are_included_alongside_consumables() { + val all = listOf( + plan("monthly", interval = "month"), + plan("remove_ads", interval = "lifetime"), + plan("coins_550", interval = "lifetime", consumable = true, grant = 550), + ) + + val store = products(all) + + // Both non-subscription shapes belong in a store; only the subscription is filtered out. + assertEquals(setOf("remove_ads", "coins_550"), store.map { it.id }.toSet()) + } + + @Test + fun a_consumable_whose_interval_collapsed_to_lifetime_is_still_a_consumable() { + // The /config mapper writes interval = "lifetime" for any null interval, so this row is + // indistinguishable from a one-time unlock by interval alone. isConsumable is what Play + // uses to pick consumePurchase over acknowledgePurchase — getting it wrong leaves the sku + // OWNED forever and the buyer unable to repurchase, with nothing erroring. + val coins = plan("coins_550", interval = "lifetime", consumable = true, grant = 550) + val unlock = plan("remove_ads", interval = "lifetime", consumable = false) + + assertTrue(coins.isConsumable) + assertFalse(unlock.isConsumable) + assertEquals(coins.interval, unlock.interval) // same interval, different finishing call + } + + @Test + fun grant_quantity_defaults_to_zero_so_a_misconfigured_pack_grants_nothing() { + // 0 is the safe default for an older server that does not send grant_quantity: a pack that + // visibly grants nothing gets fixed, whereas a guessed non-zero default would silently + // credit currency nobody configured. + val unconfigured = plan("coins_broken", interval = "lifetime", consumable = true) + + assertEquals(0, unconfigured.grantQuantity) + } +} diff --git a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/di/PayCraftIdentityBinderTest.kt b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/di/PayCraftIdentityBinderTest.kt index a5a1f2b1..0d7b9d58 100644 --- a/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/di/PayCraftIdentityBinderTest.kt +++ b/cmp-paycraft/src/commonTest/kotlin/com/mobilebytelabs/paycraft/di/PayCraftIdentityBinderTest.kt @@ -49,6 +49,9 @@ class PayCraftIdentityBinderTest { ) = PayCraftIdentityBinder( email = emails, repository = { repo }, + // No store: these tests cover the identity binding only, and with nothing persisted the + // consumable drain has nowhere to record an intent so it does not run (PayCraftSetup). + store = { null }, apiKey = "pk_test_binder", testApiKey = null, // Mock: this must not reach a network. A Cloud backend would make the test depend on the diff --git a/dashboard/__tests__/lib/inapp-product-sync.test.ts b/dashboard/__tests__/lib/inapp-product-sync.test.ts new file mode 100644 index 00000000..0247bbcb --- /dev/null +++ b/dashboard/__tests__/lib/inapp-product-sync.test.ts @@ -0,0 +1,113 @@ +/** + * Consumable / one-time provider sync — the pure pieces. + * + * Both stores accept a wrong value here happily, and neither failure surfaces until a real customer + * is involved: + * + * - Play one-time products moved from the legacy `inappproducts` resource (retired — every call + * 402/403s with "Please migrate to the new publishing API") to `monetization.onetimeproducts`, + * which takes a `Money` of units+nanos instead of a `priceMicros` string. The listings shape + * changed with it, from a language-keyed object to an array. + * - Apple's `inAppPurchaseType` is IMMUTABLE after creation. A coin pack created as NON_CONSUMABLE + * can be bought exactly once per Apple ID, forever, and the only fix is a new product under a + * new id — so this mapping has to be right the first time, not fixable later. + * - Play keys regional prices by REGION while PayCraft carries them by CURRENCY. Comparing the two + * directly ("USD" vs "US") silently prices almost nothing, which looks like a successful sync. + */ + +import { ascInAppPurchaseType, sanitizeAscInAppProductId } from "@/lib/appstore-inapp-sync" +import { oneTimeListings, regionalConfigsFor, sanitizeInAppProductId } from "@/lib/googleplay-inapp-sync" + +describe("regionalConfigsFor", () => { + it("maps PayCraft currencies to Play REGIONS, not to currency codes", () => { + // The bug this pins: comparing "USD" against a region code. It matches almost nothing, so the + // product syncs with a near-empty region set and looks successful. + const out = regionalConfigsFor([ + { currency: "USD", amountCents: 199 }, + { currency: "GBP", amountCents: 169 }, + ]) + expect(out.map((r) => r.regionCode)).toEqual(["US", "GB"]) + expect(out[0].price).toEqual({ currencyCode: "USD", units: "1", nanos: 990000000 }) + expect(out.every((r) => r.availability === "AVAILABLE")).toBe(true) + }) + + it("keeps the FIRST price when two currencies collapse to one region", () => { + // Many currencies map to one region; a later row must not silently overwrite an earlier price. + const out = regionalConfigsFor([ + { currency: "USD", amountCents: 199 }, + { currency: "USD", amountCents: 999 }, + ]) + expect(out).toHaveLength(1) + expect(out[0].price.units).toBe("1") + }) + + it("drops a currency that maps to no Play region", () => { + expect(regionalConfigsFor([{ currency: "ZZZ", amountCents: 100 }])).toEqual([]) + }) + + it("encodes money as units+nanos, the monetization.* Money shape", () => { + // The legacy resource took `priceMicros` (minor units x 10,000, a STRING). This resource takes + // a Money. A priceMicros value sent here is not a 10x error, it is a rejected request — which + // is the safer failure, and the reason toPriceMicros was deleted rather than ported. + const [row] = regionalConfigsFor([{ currency: "EUR", amountCents: 1000 }]) + expect(row.price).toEqual({ currencyCode: "EUR", units: "10", nanos: 0 }) + }) +}) + +describe("oneTimeListings", () => { + it("is an ARRAY of languageCode entries, not a language-keyed object", () => { + // `inappproducts` keyed listings by language ({"en-US": {...}}); this resource takes a list. + // Sending the old shape is a 400 on a required field. + const l = oneTimeListings("300 Ripples", 300) + expect(Array.isArray(l)).toBe(true) + expect(l[0].languageCode).toBe("en-US") + expect(l[0].title).toBe("300 Ripples") + }) + + it("describes what the pack GRANTS when no store description is given", () => { + expect(oneTimeListings("300 Ripples", 300)[0].description).toBe("Adds 300 to your balance instantly.") + }) + + it("respects Play's length caps", () => { + const l = oneTimeListings("x".repeat(200), 300, "y".repeat(200)) + expect(l[0].title.length).toBeLessThanOrEqual(55) + expect(l[0].description.length).toBeLessThanOrEqual(80) + }) +}) + +describe("ascInAppPurchaseType", () => { + it("maps a consumable to CONSUMABLE", () => { + expect(ascInAppPurchaseType("consumable")).toBe("CONSUMABLE") + }) + + it("maps everything else to NON_CONSUMABLE", () => { + // The dangerous direction is the other one: a pack typed NON_CONSUMABLE is buyable once per + // Apple ID and cannot be corrected in place. + expect(ascInAppPurchaseType("one_time")).toBe("NON_CONSUMABLE") + expect(ascInAppPurchaseType("subscription")).toBe("NON_CONSUMABLE") + }) +}) + +describe("product id sanitisation", () => { + it("makes a Play-legal managed product id", () => { + expect(sanitizeInAppProductId("cappy_ripples_550")).toBe("cappy_ripples_550") + // Trailing separators are stripped — Play rejects an id ending in "_" or ".". + expect(sanitizeInAppProductId("Cappy Ripples 550!")).toBe("cappy_ripples_550") + }) + + it("never returns an empty id", () => { + // An empty sku would otherwise produce "" and create a nameless product. + expect(sanitizeInAppProductId("!!!")).toBe("product") + expect(sanitizeInAppProductId("")).toBe("product") + }) + + it("makes an ASC-legal reverse-DNS product id", () => { + expect(sanitizeAscInAppProductId("com.mobilebytesensei.cappy", "cappy_ripples_550")).toBe( + "com.mobilebytesensei.cappy.cappyripples550", + ) + }) + + it("falls back rather than emitting a trailing-dot id", () => { + expect(sanitizeAscInAppProductId("com.example.app", "!!!")).toBe("com.example.app.product") + }) +}) diff --git a/dashboard/__tests__/lib/product-write.test.ts b/dashboard/__tests__/lib/product-write.test.ts index cf4714c3..df6a873f 100644 --- a/dashboard/__tests__/lib/product-write.test.ts +++ b/dashboard/__tests__/lib/product-write.test.ts @@ -193,3 +193,78 @@ describe("toRpcRow", () => { expect(out.active).toBe("false") }) }) + +/** + * Consumable packs — the `grant_quantity` column (migration 158). + * + * The DB has accepted this column since `tenant_products_upsert` was fixed to pass it, but nothing + * in the API layer carried it: not the merge set, not validation, not the MCP schemas. That makes + * two silent failures possible, and both end with a buyer who paid and received nothing: + * + * 1. a consumable created with no grant_quantity sells for real money and credits zero; + * 2. a product created correctly then PATCHed for any unrelated field has grant_quantity dropped + * by the upsert's UPDATE arm — the same "everything the patch didn't mention got nulled" class + * this file exists for, on the one column that represents what the customer bought. + */ +describe("grant_quantity (consumable packs)", () => { + const pack = { + ...existing, + id: "p-2", + sku: "cappy_ripples_550", + type: "consumable", + display_name: "550 Ripples", + interval: null, + grant_quantity: 550, + } + + it("refuses a consumable that grants nothing", () => { + const r = buildProductRow(TENANT, "cappy_ripples_550", null, { + display_name: "550 Ripples", + type: "consumable", + base_price_cents: 499, + }) + expect(r).toHaveProperty("error", "grant_quantity_required_for_consumable") + }) + + it("refuses a consumable whose grant is explicitly zero", () => { + const r = buildProductRow(TENANT, "cappy_ripples_550", null, { + display_name: "550 Ripples", + type: "consumable", + grant_quantity: 0, + }) + expect(r).toHaveProperty("error", "grant_quantity_required_for_consumable") + }) + + it("accepts a consumable that grants a positive amount", () => { + const r = buildProductRow(TENANT, "cappy_ripples_550", null, { + display_name: "550 Ripples", + type: "consumable", + grant_quantity: 550, + }) as { row: Record } + expect(r).not.toHaveProperty("error") + expect(r.row.grant_quantity).toBe(550) + }) + + it("carries grant_quantity through a patch that does not mention it", () => { + // The regression that matters: renaming a pack must not quietly make it grant nothing. + const r = buildProductRow(TENANT, "cappy_ripples_550", pack, { + display_name: "550 Ripples (Best value)", + }) as { row: Record } + expect(r).not.toHaveProperty("error") + expect(r.row.grant_quantity).toBe(550) + expect(r.row.display_name).toBe("550 Ripples (Best value)") + }) + + it("rejects a negative grant", () => { + const r = buildProductRow(TENANT, "cappy_ripples_550", pack, { grant_quantity: -1 }) + expect(r).toHaveProperty("error", "grant_quantity_must_be_non_negative_integer") + }) + + it("leaves non-consumables alone", () => { + // A subscription has nothing to grant; requiring one here would break every existing product. + const r = buildProductRow(TENANT, "cappy_plus_annual", existing, { + display_name: "Cappy Annual 2", + }) + expect(r).not.toHaveProperty("error") + }) +}) diff --git a/dashboard/app/api/v1/products/route.ts b/dashboard/app/api/v1/products/route.ts index 24c492ba..b787829c 100644 --- a/dashboard/app/api/v1/products/route.ts +++ b/dashboard/app/api/v1/products/route.ts @@ -10,7 +10,7 @@ const PRODUCT_WRITE_COLUMNS = "id, sku, type, display_name, trial_enabled, trial_duration_days, trial_per_platform, " + "attaches_to_product_id, interval, base_price_cents, base_currency, display_order, active, " + "pricing_mode, global_price_cents, global_currency, discount_percent, discount_ends_at, " + - "play_product_id, app_store_product_id" + "play_product_id, app_store_product_id, grant_quantity" const isPlainObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v) @@ -26,6 +26,7 @@ export async function GET(req: Request) { table: "tenant_products", columns: "id, sku, display_name, type, interval, base_price_cents, base_currency, active, " + + "grant_quantity, " + "trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, " + "razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test, live_plan_ids_verified, " + "play_product_id, app_store_product_id, sync_state, created_at, updated_at", diff --git a/dashboard/lib/appstore-inapp-sync.ts b/dashboard/lib/appstore-inapp-sync.ts new file mode 100644 index 00000000..1e9f8411 --- /dev/null +++ b/dashboard/lib/appstore-inapp-sync.ts @@ -0,0 +1,295 @@ +/** + * Create / update an App Store CONSUMABLE or NON-CONSUMABLE in-app purchase for a PayCraft + * product, via App Store Connect's `inAppPurchases` v2 resource. + * + * GET /v1/apps/{appId}/inAppPurchasesV2?filter[productId]=… (probe) + * POST /v2/inAppPurchases (create) + * PATCH /v2/inAppPurchases/{id} (update name/review note) + * GET /v2/inAppPurchases/{id}/pricePoints?filter[territory]=USA + * POST /v1/inAppPurchasePriceSchedules (set the price) + * POST /v1/inAppPurchaseLocalizations (name + description) + * Docs: https://developer.apple.com/documentation/appstoreconnectapi/app_store/managing_in-app_purchases + * + * ## Separate from appstore-product-sync for the same reason as Play + * + * Subscriptions live under `/v1/subscriptions` inside a subscription GROUP (so upgrade/downgrade + * works between them). A consumable has no group, no cadence, and no introductory offer — and its + * price is set through a PRICE SCHEDULE rather than per-territory price rows. Nothing about the + * subscription path transfers except the auth and `resolveAppId`, both imported here. + * + * ## Here Apple DOES model consumable-ness + * + * Unlike Play, `inAppPurchaseType` is a real attribute: `CONSUMABLE` may be bought repeatedly, + * `NON_CONSUMABLE` is owned once. Getting it wrong is not cosmetic — a coin pack created as + * NON_CONSUMABLE can be purchased exactly once per Apple ID, forever, and the fix is a NEW product + * because the type is immutable after creation. So the type is derived from PayCraft's own + * `type` column rather than defaulted. + * + * ## Why so much of this reports rather than throws + * + * A freshly created IAP sits in MISSING_METADATA until it has a localization, a price, and (for + * review) a screenshot. That is the NORMAL end state of a first sync, not a failure — so the + * result carries `state` and the caller surfaces it, in the same spirit as the subscription path's + * "created but not yet active" reporting. Treating it as an error would make every first run look broken. + */ + +import { ascFetch, type AppStoreCreds, type AppStorePriceInput } from "./appstore-product-sync" +import { appStoreConnectToken } from "./store-jwt" + +export interface AppStoreInAppSyncResult { + /** The ASC productId written back to tenant_products.app_store_product_id. */ + appStoreProductId: string + /** ASC resource id (needed for price/localization follow-ups). */ + inAppPurchaseId?: string + created: boolean + /** ASC review state, e.g. MISSING_METADATA / READY_TO_SUBMIT / APPROVED. */ + state?: string + priced: boolean + localized: boolean + error?: string + warning?: string +} + +/** ASC productId: reverse-DNS-ish, alphanumeric + '.', unique per app. */ +export function sanitizeAscInAppProductId(bundleId: string, sku: string): string { + const tail = sku.toLowerCase().replace(/[^a-z0-9]/g, "") + return `${bundleId}.${tail || "product"}`.slice(0, 100) +} + +/** + * PayCraft type → ASC `inAppPurchaseType`. + * + * IMMUTABLE after creation on Apple's side, which is why this is a total mapping with no default: + * a wrong guess here cannot be patched later, only replaced with a new product under a new id. + */ +export function ascInAppPurchaseType(paycraftType: string): "CONSUMABLE" | "NON_CONSUMABLE" { + return paycraftType === "consumable" ? "CONSUMABLE" : "NON_CONSUMABLE" +} + +async function findExisting( + token: string, + appId: string, + productId: string, +): Promise<{ id: string; state?: string } | null> { + const res = await ascFetch( + token, + `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(productId)}&limit=1`, + ) + if (!res.ok) return null + const row = (await res.json())?.data?.[0] + return row?.id ? { id: row.id, state: row?.attributes?.state } : null +} + +/** Name + description. Required before the IAP can leave MISSING_METADATA. */ +async function ensureLocalization( + token: string, + iapId: string, + productName: string, + description: string, +): Promise<{ ok: boolean; reason?: string }> { + const listRes = await ascFetch( + token, + `/v2/inAppPurchases/${iapId}/inAppPurchaseLocalizations?limit=200`, + ) + if (listRes.ok) { + const rows: any[] = (await listRes.json())?.data ?? [] + const enUs = rows.find((r) => r?.attributes?.locale === "en-US") + if (enUs?.id) { + const patch = await ascFetch(token, `/v1/inAppPurchaseLocalizations/${enUs.id}`, { + method: "PATCH", + body: JSON.stringify({ + data: { + type: "inAppPurchaseLocalizations", + id: enUs.id, + // name is what the purchase sheet shows; description is review-facing. + attributes: { name: productName.slice(0, 30), description: description.slice(0, 45) }, + }, + }), + }) + return patch.ok ? { ok: true } : { ok: false, reason: `localization patch ${patch.status}` } + } + } + + const res = await ascFetch(token, `/v1/inAppPurchaseLocalizations`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchaseLocalizations", + attributes: { + locale: "en-US", + name: productName.slice(0, 30), + description: description.slice(0, 45), + }, + relationships: { inAppPurchaseV2: { data: { type: "inAppPurchases", id: iapId } } }, + }, + }), + }) + return res.ok ? { ok: true } : { ok: false, reason: `localization create ${res.status}` } +} + +/** + * Set the price via a price SCHEDULE. + * + * Apple prices an IAP by picking one of ITS OWN price points rather than accepting an arbitrary + * amount, so the USD target is matched to the nearest available point — the same nearest-point + * search the subscription path uses. An exact match is not guaranteed to exist, and silently + * skipping when it does not would leave a $0 product that looks configured. + */ +async function ensurePrice( + token: string, + iapId: string, + usd: AppStorePriceInput, +): Promise<{ ok: boolean; reason?: string }> { + const ppRes = await ascFetch( + token, + `/v2/inAppPurchases/${iapId}/pricePoints?filter[territory]=USA&limit=200`, + ) + if (!ppRes.ok) return { ok: false, reason: `price points unavailable (${ppRes.status})` } + const points: any[] = (await ppRes.json())?.data ?? [] + if (points.length === 0) return { ok: false, reason: "App Store returned no USD price points" } + + const target = usd.amountCents / 100 + let best = points[0] + let bestDelta = Number.POSITIVE_INFINITY + for (const point of points) { + const price = parseFloat(point?.attributes?.customerPrice ?? "NaN") + if (!Number.isFinite(price)) continue + const delta = Math.abs(price - target) + if (delta < bestDelta) { + bestDelta = delta + best = point + } + } + + const res = await ascFetch(token, `/v1/inAppPurchasePriceSchedules`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchasePriceSchedules", + relationships: { + inAppPurchase: { data: { type: "inAppPurchases", id: iapId } }, + // baseTerritory + one manual price; Apple derives the rest of the world from its own + // equalisation table, which is why no per-territory fan-out is needed here. + baseTerritory: { data: { type: "territories", id: "USA" } }, + manualPrices: { data: [{ type: "inAppPurchasePrices", id: "${newPrice}" }] }, + }, + }, + included: [ + { + type: "inAppPurchasePrices", + id: "${newPrice}", + attributes: { startDate: null }, + relationships: { + inAppPurchasePricePoint: { data: { type: "inAppPurchasePricePoints", id: best.id } }, + }, + }, + ], + }), + }) + return res.ok ? { ok: true } : { ok: false, reason: `price schedule ${res.status}` } +} + +/** + * Create or update the in-app purchase for [sku]. Idempotent: an existing IAP is patched and + * re-priced rather than refused, so a re-sync after a price change converges. + */ +export async function syncInAppPurchaseToAppStore( + creds: AppStoreCreds, + _paycraftProductId: string, // logging correlation only + sku: string, + productName: string, + paycraftType: string, + prices: AppStorePriceInput[], + grantQuantity: number | null, + existingAppStoreProductId?: string, + storeDescription?: string | null, +): Promise { + const token = await appStoreConnectToken(creds) + const appId = await resolveAppIdLocal(token, creds.bundleId) + + const productId = existingAppStoreProductId || sanitizeAscInAppProductId(creds.bundleId, sku) + const iapType = ascInAppPurchaseType(paycraftType) + const description = + storeDescription || + (grantQuantity && grantQuantity > 0 ? `Adds ${grantQuantity} to your balance.` : productName) + + let existing = await findExisting(token, appId, productId) + let created = false + + if (!existing) { + const res = await ascFetch(token, `/v2/inAppPurchases`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchases", + attributes: { + name: productName.slice(0, 64), + productId, + inAppPurchaseType: iapType, + // Family Sharing OFF for a consumable: sharing a coin pack across a family would let + // one purchase credit several balances. Apple also forbids changing this later for + // consumables, so the conservative value is the only safe one to create with. + familySharable: false, + reviewNote: `PayCraft ${iapType.toLowerCase()} — grants ${grantQuantity ?? 0} units per purchase.`, + }, + relationships: { app: { data: { type: "apps", id: appId } } }, + }, + }), + }) + if (!res.ok) { + return { + appStoreProductId: productId, + created: false, + priced: false, + localized: false, + error: `inAppPurchases.create failed (${res.status}): ${(await res.text()).slice(0, 300)}`, + } + } + const body = await res.json() + existing = { id: body?.data?.id, state: body?.data?.attributes?.state } + created = true + } + + if (!existing?.id) { + return { + appStoreProductId: productId, + created, + priced: false, + localized: false, + error: "App Store did not return an in-app purchase id", + } + } + + const loc = await ensureLocalization(token, existing.id, productName, description) + const usd = prices.find((p) => p.currency.toUpperCase() === "USD") ?? prices[0] + const price = usd + ? await ensurePrice(token, existing.id, usd) + : { ok: false, reason: "no price configured" } + + return { + appStoreProductId: productId, + inAppPurchaseId: existing.id, + created, + state: existing.state, + localized: loc.ok, + priced: price.ok, + // Partial completion is a WARNING, not an error: the product exists and is correct as far as + // it got, and the next sync converges. An error here would roll a usable artifact back to + // "nothing happened" in the caller's summary. + warning: [loc.ok ? null : loc.reason, price.ok ? null : price.reason] + .filter(Boolean) + .join("; ") || undefined, + } +} + +/** Local copy of the app lookup — appstore-product-sync keeps its own private. */ +async function resolveAppIdLocal(token: string, bundleId: string): Promise { + const res = await ascFetch( + token, + `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`, + ) + if (!res.ok) throw new Error(`[appstore-inapp-sync] apps.list(${bundleId}) failed (${res.status})`) + const app = (await res.json())?.data?.[0] + if (!app?.id) throw new Error(`[appstore-inapp-sync] no app found for bundleId ${bundleId}`) + return app.id as string +} diff --git a/dashboard/lib/cashfree-product-sync.ts b/dashboard/lib/cashfree-product-sync.ts index bee4e2f1..8e35d9ad 100644 --- a/dashboard/lib/cashfree-product-sync.ts +++ b/dashboard/lib/cashfree-product-sync.ts @@ -1,3 +1,4 @@ +import type { SyncProductType } from "./product-write" import { cashfreeCreatePaymentLink, cashfreeGetPaymentLink, @@ -42,7 +43,7 @@ export async function syncProductToCashfree( tenantId: string, productId: string, productName: string, - productType: "subscription" | "trial" | "lifetime", + productType: SyncProductType, prices: PriceInput[], appId: string, secret: string, diff --git a/dashboard/lib/checkout-router.ts b/dashboard/lib/checkout-router.ts index 47551965..e1c24cd2 100644 --- a/dashboard/lib/checkout-router.ts +++ b/dashboard/lib/checkout-router.ts @@ -1,3 +1,4 @@ +import type { SyncProductType } from "./product-write" import { createClient as createServiceClient } from "@supabase/supabase-js" import { buildUpiLink, generateUpiReference, paiseToRupees } from "@/lib/upi" import { recordUpiIntent } from "@/lib/upi-intent-recorder" @@ -57,7 +58,7 @@ export interface CheckoutRouteRequest { export interface ProductForRouting { id: string - type: "subscription" | "trial" | "lifetime" + type: SyncProductType display_name: string base_price_cents: number base_currency: string diff --git a/dashboard/lib/googleplay-inapp-sync.ts b/dashboard/lib/googleplay-inapp-sync.ts new file mode 100644 index 00000000..e7463a58 --- /dev/null +++ b/dashboard/lib/googleplay-inapp-sync.ts @@ -0,0 +1,304 @@ +/** + * Create / update a Google Play ONE-TIME PRODUCT for a PayCraft consumable or one_time product, + * via the Play Developer API v3 `monetization.onetimeproducts` resource. + * + * GET /applications/{packageName}/oneTimeProducts/{productId} (probe) + * PATCH /applications/{packageName}/onetimeproducts/{productId} (upsert) + * Docs: https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.onetimeproducts + * + * ## This module used to call `inappproducts`, and production rejected it + * + * The first version of this file used the legacy `inappproducts` resource (POST to create, PUT to + * update, `priceMicros` strings). Every sync failed with: + * + * inappproducts.get failed (403): Please migrate to the new publishing API. + * + * — a PERMISSION_DENIED that is not about permissions. Google has retired the legacy in-app + * products API, and `monetization.onetimeproducts` replaces it. Confirmed against the live + * discovery document (`$discovery/rest?version=v3`) rather than inferred: `inappproducts` is still + * LISTED there with all its methods, so its presence in the API surface proves nothing about + * whether a call will be served. + * + * ## Two things about this resource that will bite + * + * 1. **The path casing is inconsistent in Google's own API.** `get`/`list` are served at + * `/oneTimeProducts` (camelCase) while `patch` is at `/onetimeproducts` (all lowercase). A GET + * against the lowercase spelling returns an HTML 404 — not a JSON API error — so it reads like + * the resource does not exist rather than like a typo. Both spellings below are deliberate; + * normalising them to one will break whichever call you normalise away from. + * 2. **There is no `insert`.** Creation is `patch` + `allowMissing=true`, which makes this + * naturally idempotent: the same call creates or converges a product. `updateMask` and + * `regionsVersion.version` are both REQUIRED on every patch. + * + * ## Why this is a separate module from googleplay-product-sync + * + * One-time products and subscriptions are different RESOURCES, not two shapes of one call: they + * differ in endpoint, in body, and in how availability is expressed (a base plan is activated by + * its own endpoint; a purchase option's `state` is OUTPUT-ONLY and follows from availability plus + * app publication). The subscription path stays untouched and this sits beside it. + * + * Price encoding, however, is now SHARED. The legacy resource wanted `priceMicros` (minor units × + * 10,000, as a string); `monetization.*` wants a `Money` of `{currencyCode, units, nanos}`, which + * is exactly what the subscription path's `toPlayMoney` already produces — including the + * zero-decimal handling. So the local `toPriceMicros` helper this module used to export is gone + * rather than ported: a second money converter is a second place for an exponent slip, and the + * surviving one is the one with a live-tested caller. + * + * ## Consumable vs one-time, and why Play does not care here + * + * Whether a purchase is CONSUMED (re-buyable, e.g. a coin pack) or kept forever (a lifetime + * unlock) is decided by the CLIENT at finish time — `consumeAsync` vs `acknowledgePurchase` — + * which the SDK already picks from `BillingPlan.isConsumable`. Both PayCraft types therefore map + * to the same Play artifact, and "consumable" is not expressed in this body at all. Looking for a + * consumable flag here is a category error. + */ + +import { + type GooglePlayCreds, + type GooglePlayPriceInput, + CURRENCY_REGION, + REGIONS_VERSION, + playFetch, + shortPlayError, + toPlayMoney, +} from "./googleplay-product-sync" +import { playAccessToken, type PlayServiceAccountJson } from "./store-jwt" + +export interface GooglePlayInAppSyncResult { + /** The Play one-time-product id written back to tenant_products.play_product_id. */ + playProductId: string + created: boolean + /** True when the product is purchasable after this sync. */ + active: boolean + error?: string + warning?: string +} + +/** Play listing caps: title ≤ 55, description ≤ 80. Over-length is a 400, not a truncation. */ +const TITLE_MAX = 55 +const DESCRIPTION_MAX = 80 + +/** + * The single purchase option every PayCraft pack gets. + * + * Deterministic and immutable, like the product id: a second option id on a re-sync would ADD an + * option rather than converge the existing one, leaving two ways to buy the same pack at whatever + * prices each was last written with. + */ +const BUY_OPTION_ID = "buy" + +/** One-time product ids: lowercase alphanumeric, `_` and `.`, must start with a letter or digit. */ +export function sanitizeInAppProductId(sku: string): string { + const id = sku.toLowerCase().replace(/[^a-z0-9._]/g, "_").replace(/^[._]+|[._]+$/g, "") + return (id || "product").slice(0, 40).replace(/[._]+$/g, "") || "product" +} + +/** `listings` is an ARRAY on this resource (it was a language-keyed object on `inappproducts`). */ +export function oneTimeListings( + productName: string, + grantQuantity: number | null, + storeDescription?: string | null, +) { + // A pack's description should say what it GRANTS — that is the entire product. Falling back to + // the name alone would ship "550 Ripples / 550 Ripples", which tells a buyer nothing they did + // not already see on the button. + const fallback = grantQuantity && grantQuantity > 0 + ? `Adds ${grantQuantity} to your balance instantly.` + : productName + return [{ + languageCode: "en-US", + title: productName.slice(0, TITLE_MAX), + description: (storeDescription || fallback).slice(0, DESCRIPTION_MAX), + }] +} + +/** + * PayCraft prices (by CURRENCY) → Play `regionalPricingAndAvailabilityConfigs` (by REGION). + * + * The mapping is the shared `CURRENCY_REGION` collapse the subscription path uses (many currencies + * → one region, first price for a region wins), so a pack and a plan can never end up sold in + * different places. Matching a currency code against a region code directly would silently price + * almost nothing — "USD" is not "US". + */ +export function regionalConfigsFor(prices: GooglePlayPriceInput[]) { + const seen = new Set() + const out: Array<{ regionCode: string; price: ReturnType; availability: string }> = [] + for (const { currency, amountCents } of prices) { + const region = CURRENCY_REGION[currency.toUpperCase()] + if (!region || seen.has(region)) continue + seen.add(region) + out.push({ + regionCode: region, + price: toPlayMoney(currency, amountCents), + availability: "AVAILABLE", + }) + } + return out +} + +/** + * Create or converge the one-time product for [sku]. Idempotent by construction — `patch` with + * `allowMissing` is both the create and the update. + */ +export async function syncInAppProductToGooglePlay( + creds: GooglePlayCreds, + _paycraftProductId: string, // logging correlation only + sku: string, + productName: string, + prices: GooglePlayPriceInput[], + grantQuantity: number | null, + existingPlayProductId?: string, + storeDescription?: string | null, +): Promise { + const sa = JSON.parse(creds.serviceAccountJson) as PlayServiceAccountJson + const token = await playAccessToken(sa) + const pkg = creds.packageName + if (!pkg) throw new Error("googleplay-inapp-sync: missing package_name in tenant store config") + + const productId = existingPlayProductId || sanitizeInAppProductId(sku) + const regionalConfigs = regionalConfigsFor(prices) + if (regionalConfigs.length === 0) { + // Not "no prices" — no price whose currency maps to a Play region. Saying which matters: a + // product priced only in a currency CURRENCY_REGION does not know looks identically unpriced. + return { + playProductId: productId, + created: false, + active: false, + error: prices.length === 0 + ? "no price configured" + : `no price currency maps to a Play region (${prices.map((p) => p.currency).join(", ")})`, + } + } + + // camelCase on purpose — see (1) in the header. This probe only decides created-vs-updated for + // the result; the patch below is an upsert either way, so a probe failure must not abort it. + const probe = await playFetch(token, `/applications/${pkg}/oneTimeProducts/${encodeURIComponent(productId)}`) + const existed = probe.ok + + const body = { + packageName: pkg, + productId, + listings: oneTimeListings(productName, grantQuantity, storeDescription), + purchaseOptions: [{ + purchaseOptionId: BUY_OPTION_ID, + // `legacyCompatible` is the field that decides whether EXISTING Play Billing flows can see + // this product at all. Purchase options are a newer PBL concept; a client querying the way + // most shipped apps do gets nothing back for a product with no legacy-compatible option, so + // the pack would exist in Play Console, report synced here, and be invisible in the app. + // Exactly one option per product may carry it, which is why there is exactly one. + buyOption: { legacyCompatible: true }, + regionalPricingAndAvailabilityConfigs: regionalConfigs, + // Price for regions Play launches in later. Without it a new market inherits no price and + // the product silently stops being purchasable there. USD and EUR are both required. + newRegionsConfig: { + availability: "AVAILABLE", + usdPrice: priceIn(prices, "USD") ?? toPlayMoney("USD", fallbackCents(prices)), + eurPrice: priceIn(prices, "EUR") ?? toPlayMoney("EUR", fallbackCents(prices)), + }, + }], + // NOTE: `purchaseOptions[].state` is OUTPUT-ONLY on this resource. There is nothing to set to + // make a pack live — it follows from availability plus a published app, which is why a freshly + // created product reports not-yet-purchasable rather than active. + } + + // updateMask + regionsVersion.version are REQUIRED; allowMissing makes patch do the create. + const res = await playFetch( + token, + `/applications/${pkg}/onetimeproducts/${encodeURIComponent(productId)}` + + `?updateMask=listings,purchaseOptions&allowMissing=true®ionsVersion.version=${REGIONS_VERSION}`, + { method: "PATCH", body: JSON.stringify(body) }, + ) + if (!res.ok) { + return { + playProductId: productId, + created: false, + active: false, + error: `onetimeproducts.patch failed (${res.status}): ${shortPlayError(await res.text())}`, + } + } + + await res.json() + + // The patch above leaves the option in DRAFT. Activating is a SEPARATE call, and it is the only + // thing that makes the pack buyable — see activatePurchaseOption. + const activation = await activatePurchaseOption(token, pkg, productId) + const active = activation.state === "ACTIVE" + + return { + playProductId: productId, + created: !existed, + active, + // Never report a DRAFT product as synced-and-done. That is exactly what let three live packs + // look shipped — `sync_state.google_play: draft`, `play_product_id` populated — while no buyer + // could see them, because nothing had ever called the activation endpoint. + warning: active + ? undefined + : activation.error + ? `written to Play, but activation failed: ${activation.error}` + : `written to Play, but the purchase option is ${activation.state ?? "not ACTIVE"} — it cannot be bought yet`, + } +} + +/** + * Activate the purchase option — WITHOUT THIS A PACK IS NEVER PURCHASABLE. + * + * `patch` creates the product in DRAFT, and `purchaseOptions[].state` is OUTPUT-ONLY, so there is + * no field on the upsert that can make it live. Activation is its own endpoint, exactly as a + * subscription's base plan has `activateBasePlan` beside its patch — this module simply never had + * the counterpart. The cost was invisible in every signal we had: the patch returned 200, the + * product existed in Play Console with correct prices in 8 regions, `play_product_id` was written + * back, and the sync reported `draft` — which reads like "Play is still processing" rather than + * "nobody can buy this". + * + * Idempotent: activating an already-ACTIVE option returns 200 and changes nothing, so a re-sync is + * safe. Failure is REPORTED, not thrown — the product itself was written correctly, and a caller + * that treated "could not activate" as "did not sync" would re-create it on every attempt. + */ +async function activatePurchaseOption( + token: string, + pkg: string, + productId: string, +): Promise<{ state?: string; error?: string }> { + // camelCase `oneTimeProducts` here, lowercase `onetimeproducts` on the patch — Google's own + // inconsistency, documented at the top of this file. Do not "tidy" these to match. + const res = await playFetch( + token, + `/applications/${pkg}/oneTimeProducts/${encodeURIComponent(productId)}/purchaseOptions:batchUpdateStates`, + { + method: "POST", + body: JSON.stringify({ + requests: [{ + activatePurchaseOptionRequest: { + packageName: pkg, + productId, + purchaseOptionId: BUY_OPTION_ID, + }, + }], + }), + }, + ) + if (!res.ok) { + return { + error: `purchaseOptions.batchUpdateStates failed (${res.status}): ${shortPlayError(await res.text())}`, + } + } + const body = await res.json() + return { state: body?.oneTimeProducts?.[0]?.purchaseOptions?.[0]?.state } +} + +/** The Money for [currency] if PayCraft priced it explicitly, else null. */ +function priceIn(prices: GooglePlayPriceInput[], currency: string) { + const row = prices.find((p) => p.currency.toUpperCase() === currency) + return row ? toPlayMoney(currency, row.amountCents) : null +} + +/** + * Minor units to use for a new-region currency PayCraft did not price. + * + * The first configured price, NOT a converted one: this module has no FX rate and inventing one is + * how `/config`'s fallback produced a 100× JPY price. An approximate same-number price in a new + * market is wrong by a margin a human can spot and fix; a derived one is wrong by 100×. + */ +function fallbackCents(prices: GooglePlayPriceInput[]): number { + return prices[0]?.amountCents ?? 0 +} diff --git a/dashboard/lib/mcp-tools.ts b/dashboard/lib/mcp-tools.ts index d8c358c4..e221d750 100644 --- a/dashboard/lib/mcp-tools.ts +++ b/dashboard/lib/mcp-tools.ts @@ -209,6 +209,11 @@ export const MCP_TOOLS: McpTool[] = [ description: "Billing cadence. Required for a subscription.", }, type: { type: "string", enum: ["subscription", "one_time", "consumable"], description: "Default: subscription." }, + grant_quantity: { type: "integer", minimum: 0, description: "Units ONE purchase grants (e.g. 550 Ripples). REQUIRED and > 0 for type:consumable — a consumable granting 0 takes the money, is consumed at the store so it cannot be bought again for free, and credits nothing. Ignored for other types." }, + grant_sku: { type: ["string", "null"], description: "The CURRENCY this product credits, when that is not the product itself. null (default) = its own sku. Give every pack size of one currency the SAME value (e.g. three Ripples packs all grant_sku 'ripples') or their balances will not add up — a user who buys the big pack sees coins that do not combine with the small one." }, + daily_earn_cap: { type: ["integer", "null"], minimum: 1, description: "Max units this sku may be credited by IN-APP ACTIVITY (reason=activity) per user per UTC day. null (default) DISABLES activity earning. Activity earns are client-asserted — no server can observe a focus session — so this ceiling is the only thing bounding what a modified client can mint. Enable deliberately." }, + ad_reward_quantity: { type: ["integer", "null"], minimum: 1, description: "Units credited per VERIFIED rewarded-ad view (reason=ad_reward). null (default) disables ad rewards. Deliberately NOT read from the ad network's callback: its reward_amount is set in the ad console, outside this system, so trusting it would let that console price your currency." }, + daily_ad_reward_cap: { type: ["integer", "null"], minimum: 1, description: "Max units creditable by ad_reward per user per UTC day. null = uncapped, which is only safe if the ad network's own frequency capping is trusted to be the limit." }, base_price_cents: { type: "integer", minimum: 0, description: "Reference price in MINOR units. Per-country prices go through paycraft_set_product_pricing." }, base_currency: { type: "string", description: "ISO 4217 for base_price_cents. Default USD." }, trial_enabled: { type: "boolean" }, @@ -255,6 +260,7 @@ export const MCP_TOOLS: McpTool[] = [ description: "Billing cadence. Required for a subscription.", }, type: { type: "string", enum: ["subscription", "one_time", "consumable"], description: "Default: subscription." }, + grant_quantity: { type: "integer", minimum: 0, description: "Units ONE purchase grants (e.g. 550 Ripples). REQUIRED and > 0 for type:consumable — a consumable granting 0 takes the money, is consumed at the store so it cannot be bought again for free, and credits nothing. Ignored for other types." }, base_price_cents: { type: "integer", minimum: 0, description: "Reference price in MINOR units. Per-country prices go through paycraft_set_product_pricing." }, base_currency: { type: "string", description: "ISO 4217 for base_price_cents. Default USD." }, trial_enabled: { type: "boolean" }, @@ -416,7 +422,12 @@ export const MCP_TOOLS: McpTool[] = [ title: "Sync one product (writes to live providers)", description: "Pushes a single product to its providers. No confirm_count — the subject is named explicitly. " + - "Optionally narrow to one provider. Requires products:sync.", + "Optionally narrow to one provider. Requires products:sync. Handles every product type: a " + + "subscription becomes a Play base plan / App Store subscription, while a consumable or " + + "one_time becomes a Play MANAGED PRODUCT (inappproducts) / App Store in-app purchase. A " + + "freshly created native product is normally NOT yet purchasable — Play needs a published " + + "APK and the App Store needs a review screenshot — and the response says so as a warning " + + "rather than reporting a success the storefront will not honour.", inputSchema: obj( { ...TENANT, id: { type: "string", description: "Product UUID." }, diff --git a/dashboard/lib/product-write.ts b/dashboard/lib/product-write.ts index 9cbbd3fb..76aa7f1e 100644 --- a/dashboard/lib/product-write.ts +++ b/dashboard/lib/product-write.ts @@ -34,6 +34,17 @@ export const VALID_INTERVALS = ["week", "month", "quarter", "semiannual", "year" /** `product_type` enum values. */ export const VALID_PRODUCT_TYPES = ["subscription", "one_time", "consumable"] as const +/** + * What a sync lane may be handed. + * + * The live enum is `subscription | one_time | consumable`; `trial` and `lifetime` are LEGACY names + * that predate it and still reach these functions from older rows and callers. Keeping them in the + * type is deliberate — the lanes all branch on `=== "subscription"` and treat everything else as a + * one-time charge, which is the correct behaviour for every member of this union, so narrowing it + * would reject rows the system still has to sell rather than fix anything. + */ +export type SyncProductType = (typeof VALID_PRODUCT_TYPES)[number] | "trial" | "lifetime" + /** `pricing_mode` enum values. */ export const VALID_PRICING_MODES = ["auto", "global", "manual"] as const @@ -61,6 +72,11 @@ export const MERGEABLE_COLUMNS = [ "global_currency", "discount_percent", "discount_ends_at", + "grant_quantity", + "grant_sku", + "daily_earn_cap", + "ad_reward_quantity", + "daily_ad_reward_cap", ] as const /** Columns the caller may never set directly. */ @@ -160,6 +176,37 @@ function validateField(key: string, raw: unknown): { value: unknown } | Validati if (!/^[A-Z]{3}$/.test(v)) return { error: `${key}_must_be_iso_4217` } return { value: v } } + case "grant_quantity": { + // Units one purchase of a consumable grants. Non-negative; 0 is legitimate for every other + // product type, which is why this is not simply `>= 1` here — the cross-field rule below is + // what refuses a consumable that grants nothing. + if (raw === null) return { value: null } + if (!Number.isInteger(raw) || (raw as number) < 0) { + return { error: "grant_quantity_must_be_non_negative_integer" } + } + return { value: raw } + } + case "grant_sku": { + // The currency this product credits. null = the product's own sku (unchanged behaviour). + // Not validated against tenant_products: a currency id deliberately has no product row of its + // own — it is a ledger partition key, not a sellable thing (migration 164). + if (raw === null) return { value: null } + if (typeof raw !== "string" || !raw.trim()) return { error: "grant_sku_must_be_a_non_empty_string" } + return { value: raw.trim() } + } + case "daily_earn_cap": + case "ad_reward_quantity": + case "daily_ad_reward_cap": { + // All three are OPT-INS, and null is the meaningful default: null daily_earn_cap disables + // activity earning for the sku, null ad_reward_quantity disables ad rewards, null + // daily_ad_reward_cap leaves ad rewards uncapped. 0 would be an ambiguous way to say "off" + // for the first two and "no rewards ever" for the third, so it is refused — say null. + if (raw === null) return { value: null } + if (!Number.isInteger(raw) || (raw as number) < 1) { + return { error: `${key}_must_be_a_positive_integer_or_null` } + } + return { value: raw } + } case "display_order": { if (!Number.isInteger(raw)) return { error: "display_order_must_be_integer" } return { value: raw } @@ -251,6 +298,14 @@ export function buildProductRow( if (!("pricing_mode" in row)) row.pricing_mode = "auto" if (!row.display_name) return { error: "display_name_required" } if (!row.interval && row.type === "subscription") return { error: "interval_required_for_subscription" } + // A consumable exists to GRANT something. Shipping one with grant_quantity 0 is the silent + // failure this rule prevents: the purchase succeeds, Play consumes it so it is non-refundable + // by the usual route, the webhook grants zero, and the buyer's balance never moves. Nothing + // errors anywhere — the money is simply gone. Refusing at the API is the only place this is + // still cheap to catch, which is why it mirrors interval_required_for_subscription above. + if (row.type === "consumable" && !(Number(row.grant_quantity) > 0)) { + return { error: "grant_quantity_required_for_consumable" } + } } return { row } diff --git a/dashboard/lib/razorpay-product-sync.ts b/dashboard/lib/razorpay-product-sync.ts index cf3a80d8..4eeca0d3 100644 --- a/dashboard/lib/razorpay-product-sync.ts +++ b/dashboard/lib/razorpay-product-sync.ts @@ -1,3 +1,4 @@ +import type { SyncProductType } from "./product-write" import type { SupabaseClient } from "@supabase/supabase-js" import { getConnectedRazorpayClient } from "./razorpay-client" @@ -74,7 +75,7 @@ export async function syncProductToRazorpay( tenantId: string, productId: string, productName: string, - productType: "subscription" | "trial" | "lifetime", + productType: SyncProductType, interval: BillingInterval | string | null, prices: RazorpayPriceInput[], mode: "test" | "live" = "live", diff --git a/dashboard/lib/stripe-product-sync.ts b/dashboard/lib/stripe-product-sync.ts index c3a73ed8..58b2c0f3 100644 --- a/dashboard/lib/stripe-product-sync.ts +++ b/dashboard/lib/stripe-product-sync.ts @@ -1,3 +1,4 @@ +import type { SyncProductType } from "./product-write" import type { SupabaseClient } from "@supabase/supabase-js" import type Stripe from "stripe" import { getConnectedStripeClient } from "./stripe-client" @@ -71,7 +72,7 @@ export async function syncProductToStripe( tenantId: string, productId: string, productName: string, - productType: "subscription" | "trial" | "lifetime", + productType: SyncProductType, stripeInterval: StripeInterval | null, prices: PriceInput[], existing: SyncProductOptions = {}, diff --git a/dashboard/lib/stripe-route-helper.ts b/dashboard/lib/stripe-route-helper.ts index 7093ea47..74cc8e46 100644 --- a/dashboard/lib/stripe-route-helper.ts +++ b/dashboard/lib/stripe-route-helper.ts @@ -1,6 +1,8 @@ import { syncProductToStripe, toStripeInterval, type PriceInput } from "@/lib/stripe-product-sync" import { syncProductToRazorpay } from "@/lib/razorpay-product-sync" import { syncProductToCashfree } from "@/lib/cashfree-product-sync" +import { syncInAppProductToGooglePlay } from "./googleplay-inapp-sync" +import { syncInAppPurchaseToAppStore } from "./appstore-inapp-sync" import { syncProductToGooglePlay } from "@/lib/googleplay-product-sync" import { syncProductToAppStore } from "@/lib/appstore-product-sync" import { createClient } from "@/lib/supabase-server" @@ -481,9 +483,11 @@ export async function googlePlaySyncProduct( ): Promise<{ error?: string; warning?: string; skipped?: boolean; reason?: string }> { const { tenantId, productId, body, existingPlayProductId } = opts try { - if (body.type !== "subscription") { - return { skipped: true, reason: "native stores only sync subscription products" } - } + // Managed products (consumable / one-time) are a DIFFERENT Play resource — `inappproducts`, + // not `monetization.subscriptions` — so they route to their own module below rather than being + // skipped. Until 2026-10-20 this returned "native stores only sync subscription products", + // which was honest but meant a coin pack could never reach Play through PayCraft at all. + const isManagedProduct = body.type === "consumable" || body.type === "one_time" const { data: status } = await supabase .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "google_play" }) @@ -510,6 +514,34 @@ export async function googlePlaySyncProduct( // Android reads the per-platform 'android' value (0 → W2 teardown removes the offer). const trialDays = resolvePlatformTrialDays(body, "android") + if (isManagedProduct) { + const inapp = await syncInAppProductToGooglePlay( + { serviceAccountJson: decrypted.credential, packageName }, + productId, + body.sku, + body.display_name, + prices, + typeof body.grant_quantity === "number" ? body.grant_quantity : null, + existingPlayProductId, + body.store_description ?? null, + ) + if (inapp.error) return { error: inapp.error } + await supabase.rpc("tenant_products_set_store_ids", { + p_id: productId, + p_play_product_id: inapp.playProductId, + p_app_store_product_id: null, + }) + // Same reporting discipline as the subscription path: a managed product that exists but is + // not yet purchasable is a WARNING the operator must see, never a silent success. + if (!inapp.active) { + return { + warning: + "synced, but the Play managed product is not active — publish the app on Play (upload an APK/AAB), then re-sync", + } + } + return {} + } + const result = await syncProductToGooglePlay( { serviceAccountJson: decrypted.credential, packageName }, productId, @@ -580,9 +612,11 @@ export async function appStoreSyncProduct( ): Promise<{ error?: string; warning?: string; skipped?: boolean; reason?: string }> { const { tenantId, productId, body, existingAppStoreProductId } = opts try { - if (body.type !== "subscription") { - return { skipped: true, reason: "native stores only sync subscription products" } - } + // Consumables / one-time unlocks are a different ASC resource (`inAppPurchases`, no + // subscription group, no cadence, price via a SCHEDULE), so they route to their own module + // below. Apple — unlike Play — models consumable-ness explicitly, and the type is IMMUTABLE + // after creation, so it is derived from the PayCraft row rather than defaulted. + const isInAppPurchase = body.type === "consumable" || body.type === "one_time" const { data: status } = await supabase .rpc("tenant_providers_store_status", { p_tenant_id: tenantId, p_provider: "app_store" }) @@ -608,6 +642,42 @@ export async function appStoreSyncProduct( // the StoreKit introductory offer). const trialDays = resolvePlatformTrialDays(body, "ios") + if (isInAppPurchase) { + const iap = await syncInAppPurchaseToAppStore( + { + keyId: cfg.key_id, + issuerId: cfg.issuer_id, + bundleId: cfg.bundle_id, + privateKeyP8: decrypted.credential, + }, + productId, + body.sku, + body.display_name, + body.type, + prices, + typeof body.grant_quantity === "number" ? body.grant_quantity : null, + existingAppStoreProductId, + body.store_description ?? null, + ) + if (iap.error) return { error: iap.error } + await supabase.rpc("tenant_products_set_store_ids", { + p_id: productId, + p_play_product_id: null, + p_app_store_product_id: iap.appStoreProductId, + }) + // MISSING_METADATA is the NORMAL state of a freshly created IAP — it needs a localization, + // a price and a review screenshot before it can be submitted. Reported as a warning so the + // operator knows there is a step left, never as success and never as failure. + if (iap.warning || iap.state === "MISSING_METADATA") { + return { + warning: + iap.warning ?? + "synced, but the App Store in-app purchase is still MISSING_METADATA — add a review screenshot in App Store Connect, then submit it with your next app version", + } + } + return {} + } + const result = await syncProductToAppStore( { keyId: cfg.key_id, diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 14786545..eb38c494 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -97,6 +97,143 @@ You must also create the webhook Edge Function at `server/functions/paddle-webho --- +## Consumables & one-time products — provider support + +> **Scope, deliberately.** Consumables are implemented for **five providers only**: +> `stripe`, `razorpay`, `cashfree`, `google_play`, `app_store`. Every other webhook in +> `supabase/functions/` is **subscription-only and intentionally left unimplemented** for +> consumables. See [Not implemented](#not-implemented-for-consumables) for why, and for what a +> future implementer needs. + +A consumable (coin pack, credits) differs from a subscription in two places, and both must be +built for a provider to count as supported: + +| half | what it does | where | +|---|---|---| +| **sync** | create the sellable product on the provider | `lib/*-product-sync.ts`, dispatched by `runProductSync` | +| **grant** | credit the buyer's balance when the purchase settles | `_shared/consumable-handler.ts`, called from that provider's webhook | + +A provider with only the sync half sells a pack that credits nothing. A provider with only the +grant half has nothing to sell. Neither half alone is "support". + +### Implemented + +| provider | sync (product created as) | grant (event → identifier) | +|---|---|---| +| `stripe` | one-time Price + Payment Link | `checkout.session.completed` (payment mode) → `metadata.plan_id` | +| `razorpay` | Payment Link | `payment.captured` → `notes.paycraft_product_id` | +| `cashfree` | Payment Link (one-time-only lane) | `PAYMENT_SUCCESS_WEBHOOK` → product resolved from `link_id` prefix | +| `google_play` | managed product (`inappproducts`) | RTDN `oneTimeProductNotification` type 1 → `sku` + `purchaseToken` | +| `app_store` | in-app purchase (`/v2/inAppPurchases`) | ASSN `ONE_TIME_CHARGE` → `productId` + `transactionId` | + +Only these five are **connectable** — `tenant_providers` constrains credentials to +`stripe | razorpay | cashfree` plus the `google_play | app_store` store kinds — so these are also +the only providers `paycraft_sync_product` can create a product on. + +> **One inbound attribution rule, for every provider and both product kinds.** +> +> Outbound (the v1 API and MCP) is account-authenticated: an account access token plus the app's +> api key, and `requireApiKey` refuses to act on an app the key does not reach. Inbound traffic has +> no key — the provider calls us — so the app is recovered from the event. That asymmetry is real. +> Three *different* recoveries were not, and `_shared/inbound-tenant.ts` now replaces them: +> +> | order | how | used by | +> |---|---|---| +> | 1 | tenant in the webhook URL path | the web PSPs | +> | 2 | app identifier → `tenants.app_identifier` | Play `packageName`, Apple `bundleId` | +> | 3 | sole tenant configured for that provider (warns) | any lane missing 1 and 2 | +> | 4 | `null` — genuine single-tenant self-hosted | any | +> +> More than one candidate at step 3 is **ambiguous and refused**, because crediting the wrong app's +> ledger is worse than failing a webhook the provider will redeliver. +> +> This also fixed a real defect: the store lanes previously reconciled every subscription with +> `tenant_id: null`, and a NULL-tenant row never matches `check_premium_with_device()`'s +> `tenant_id IS NOT DISTINCT FROM` filter — so `is_premium` read **false for a paying customer** on +> any multi-tenant deployment. Play and App Store subscriptions are now attributed by the same rule +> as their consumables. +> +> Resolving on the PRODUCT id is deliberately not one of the steps: two tenants can both sell a sku +> called `coins_550`, so a product-only lookup either errors on multiple rows or silently +> mis-attributes. +> +> The buyer on a store purchase is the app's OWN user id — Play's `obfuscatedExternalAccountId`, +> Apple's `appAccountToken` — recorded as `kind: "auth_user"`, `verified: true`. A purchase with no +> account id is refused rather than attributed to the purchase token, which would mint a throwaway +> subject the buyer can never sign back into. +> +> A consumable is **re-buyable without limit**: Play consumes it and Apple marks it CONSUMABLE, so +> each purchase arrives with its own token / `transactionId` — which is why the idempotency key is +> keyed on that and never on the product. + +### The grant contract + +`handleConsumablePurchase` is provider-agnostic on purpose: the per-provider differences are only +*which event means paid*, *where the product identifier lives*, and *what the stable transaction id +is*. How much to grant is never a provider decision — it is read from `tenant_products +.grant_quantity`, because a webhook payload can be stale after a catalogue edit and is +attacker-adjacent on self-hosted deployments. + +```ts +await handleConsumablePurchase({ + tenantId, // from the webhook URL path; null is refused + email, // buyer identity — resolved to an entitlement subject + productId, // PayCraft product UUID …or… + sku, // catalogue sku — whichever the provider round-trips + stableTxnId, // THE idempotency anchor; null is refused + provider, // prefixes the idempotency key + eventType, +}) +``` + +Three rules a new provider must satisfy: + +1. **A stable per-purchase id.** The key is `provider:stableTxnId`, and a redelivery must present + the same one. Cashfree is the cautionary case: its `link_id` is reused across every purchase of + a product, so keying on it would make the *second* buyer look like a redelivery of the first and + silently grant them nothing. Its **order id** is the correct anchor. +2. **A product identifier that survives the round trip.** Either the sku or the UUID — the handler + accepts both, so existing integrations do not have to be rewritten to agree on one. +3. **Route before the subscription handler.** A consumable sent to `handleSubscriptionEvent` grants + *access* instead of currency. Stripe did exactly this: a pack became a lifetime entitlement row + and the balance stayed at zero. + +### Not implemented for consumables + +`paddle` · `paystack` · `lemonsqueezy` · `flutterwave` · `midtrans` · `paypal` · `btcpay` + +These are webhook-only providers: they have no entry in `tenant_providers`, so PayCraft never +creates a product on them, and the catalogue row they reference is configured in the provider's own +dashboard. They are **not** blocked by anything in the shared handler — each is roughly fifteen +lines of wiring once the piece below is known. + +**What is missing is not code — it is the metadata contract.** Each provider carries a merchant +reference through checkout under a different field, and PayCraft's checkout lane does not currently +set one for these providers. Wiring a grant without knowing that field means guessing which key the +sku arrives under, and a wrong guess is a purchase that charges the customer and credits nothing. + +So an implementer needs, per provider: + +| provider | one-time event (identified, not verified) | unknown | +|---|---|---| +| `paddle` | `transaction.completed` | which `custom_data` key carries the product ref | +| `paystack` | `charge.success` with no `plan_code` | `metadata` key | +| `lemonsqueezy` | `order_created` | `meta.custom_data` key | +| `flutterwave` | `charge.completed` with no `payment_plan` | `meta` key | +| `midtrans` | `capture` / `settlement` | which `custom_field*` carries it | +| `paypal` | `PAYMENT.CAPTURE.COMPLETED` | whether `custom_id` or `invoice_id` is used | +| `btcpay` | `InvoiceSettled` | invoice `metadata` key | + +The events above were read from each handler and are a sound starting point. The **unknown** column +is the part that must be confirmed against a real checkout before wiring — ideally by making +PayCraft's own checkout set the field, so the contract is owned rather than discovered. + +When adding one, the failure mode to preserve is the safe one: an unrecognised identifier makes +`handleConsumablePurchase` refuse with `"no sku or product id"` and log it, rather than grant the +wrong product. + +--- + ## Adding a New Provider Run `/add-provider` in Claude Code, or follow these steps manually: diff --git a/paycraft-instructions/BILLING_STATE_SEMANTICS.md b/paycraft-instructions/BILLING_STATE_SEMANTICS.md index d7f4ee44..06904b2d 100644 --- a/paycraft-instructions/BILLING_STATE_SEMANTICS.md +++ b/paycraft-instructions/BILLING_STATE_SEMANTICS.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # BILLING_STATE_SEMANTICS.md — the billing state machine and realtime invalidation @@ -40,6 +40,23 @@ holds a nullable api key, the RPCs resolve no tenant, and the honest answer is F host that wants to distinguish "no entitlement" from "no key" asks `PayCraft.isConfigured`; a host that simply gates features needs no branch at all. +## A balance is NOT an entitlement + +`BillingState` / `EntitlementSummary` answer **access** — may this user open the premium feature. +A consumable balance answers **how many units are left**, and the two must never be folded: + +- A user with 500 coins and no subscription is `FREE`. Buying a pack does **not** make them premium. +- A premium user who spends their last coin is still `PREMIUM`. +- `isPremium` is a boolean about a plan; a balance is an integer about a currency. + +So nothing in the entitlement ladder below reads a balance, and nothing that spends reads +`isPremium`. A host that gates a feature on "has units" writes that gate itself against its own +ledger — the SDK's job ends when the units are handed over through `ConsumableGrantSink`. + +The balance is **server-authoritative** in exactly the way the entitlement is: `spend` debits +server-side and returns the server's new number. There is no local decrement, and a client that +could compute its own balance would be minting currency people pay real money for. + ## `EntitlementSummary` — gate on this, not on `BillingState` `BillingState` describes **where a purchase IS**. That is the right shape for a checkout flow and diff --git a/paycraft-instructions/FAILURE_MODES.md b/paycraft-instructions/FAILURE_MODES.md index debeace0..c30fde22 100644 --- a/paycraft-instructions/FAILURE_MODES.md +++ b/paycraft-instructions/FAILURE_MODES.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # FAILURE_MODES.md — enumerated integration failures and their source-level remedies @@ -116,3 +116,21 @@ never faked green and never spun on. | F50 | One platform's verdict inherited by another | Per-platform verdicts collapsed | — | Verdicts are per-platform and never cross-inherit | | F51 | Corpus disagrees with the SDK | Corpus stale | heal | Re-fold via `/paycraft-corpus-fold` before integrating (PCA-4) | | F52 | Corpus version ≠ resolved artifact version | `corpus-artifact-mismatch` | heal | Align the consumer's `cmp-paycraft` version, or re-fold + publish. On a `/lib-integrate` composite link there is no artifact version — the gate compares `sdk_head_commit` against source HEAD instead | + +## Consumables + +Every row below was observed in production on 2026-10-21 (mbs/cappy, three live Ripples packs). +They share a shape worth naming: **each reported success at every layer it touched.** A consumable +failure is almost never an error — it is a surface that looks configured and sells nothing. + +| # | Symptom | Cause | Class | Resolution | +|---|---|---|:--:|---| +| F53 | `PayCraftStoreSection` renders "No products available" against a tenant that HAS live packs | `isConsumable` false on every plan. The catalogue mapper filtered to `subscription`/`lifetime` BEFORE setting the flag, so `isConsumable = dto.type == "consumable"` was unreachable | heal | Fixed in the SDK (the mapper now denylists `trial` only). On an older SDK there is no host-side workaround — the flag never arrives. Verify with `PayCraft.products.isNotEmpty()` | +| F53b | The same bug, two silent cousins | The drain's `plans.filter { it.isConsumable }` matched nothing, so purchased units NEVER reached the host; and `isConsumableStoreProduct` returned false, so a pack was ACKNOWLEDGED instead of CONSUMED | heal | Same fix. The acknowledge path is the dangerous one: Play then treats the pack as owned forever and refuses to sell it again — unrecoverable for that buyer | +| F54 | Pack exists in Play Console with correct prices, `sync_state: draft`, and no buyer can see it | A one-time product is created in DRAFT and `purchaseOptions[].state` is OUTPUT-ONLY. Activation is a SEPARATE call (`purchaseOptions:batchUpdateStates`) that nothing invoked | heal | Re-sync on a dashboard that activates. `draft` reads like "Play is still processing" and is not — it means unbuyable | +| F55 | Pack is ACTIVE in Play but invisible to the app's billing client | The purchase option is not `legacyCompatible`. Purchase options are a newer PBL concept; a client querying the usual way gets nothing back | heal | Exactly one option per product may carry `legacyCompatible: true`; the sync sets it. Symptom is indistinguishable from "product not found" | +| F56 | Buyer charged, units never arrive | `onUnitsGranted` not bound, or the sink returned `true` without durably committing | heal | Bind the sink; return `true` only after the write is committed. A `false` return keeps the grant pending and retries — that is the safe answer when unsure | +| F57 | Units credited twice for one purchase | Sink not idempotent on `grantId` | heal | Make `grantId` the ledger row's PRIMARY KEY and insert with IGNORE-on-conflict. The sink IS re-invoked after a crash or a provider redelivery — by design | +| F58 | A coin pack is offered as the "upgrade" on a paywall | A plan picked by rank out of `plans`, which now contains consumables | heal | Use `PayCraft.subscriptionPlans` for any rank-based pick | +| F59 | `spend` throws and takes down a screen | Insufficient funds treated as an exception | heal | It is `Result.failure` — an ordinary outcome. Render it; a rejected spend must not move the balance | +| F60 | Catalogue write accepted, product grants nothing | A `consumable` created with no `grant_quantity` | external | The API refuses this on create. A row predating that rule still exists — the buyer is charged and receives nothing, so fix the catalogue row | diff --git a/paycraft-instructions/KEY_TIERING.md b/paycraft-instructions/KEY_TIERING.md index 4abc7d83..63cbf38e 100644 --- a/paycraft-instructions/KEY_TIERING.md +++ b/paycraft-instructions/KEY_TIERING.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # KEY_TIERING.md — publishable vs secret, and how each reaches its consumer @@ -154,3 +154,27 @@ the remedy is rotation plus a proper handoff — never "copy it into the repo so per-ecosystem local formats and has no `.env` at all. - Use a service-role key anywhere a client could reach it — including "just for a moment" inside an edge function that could have used an account token instead. + +## What a publishable key must NOT be able to reach + +The `pk_` key is shipped in client source, so it is public by construction. The tier is only +meaningful if every RPC it reaches is safe for an ANONYMOUS caller to invoke with arbitrary +arguments — and that is a property of the RPC SIGNATURE, not of the key. + +The balance RPCs are the worked example. They originally took a **subject id** as a parameter while +being granted to anon/authenticated: + +``` +entitlement_balance_get(p_subject_id, p_api_key, p_sku) -- WRONG +entitlement_balance_get(p_server_token, p_api_key, p_sku) -- correct +``` + +Taking the subject verbatim meant one holder of the publishable key — i.e. anyone who had installed +the app — could read, and then DRAIN, another subject's balance by passing their id. The key was +behaving exactly as designed; the signature was the hole. The fix was to make the server derive the +subject from the device's `server_token`, so the caller can only ever name itself, and to REVOKE the +old signatures from anon/authenticated rather than leave them beside the new ones. + +The rule this leaves: **an RPC that a `pk_` key can call must never accept an identity as an +argument.** It derives identity from a credential the caller had to possess. When reviewing a new +client-facing RPC, read its parameter list first — any `p_*_id` naming a principal is the defect. diff --git a/paycraft-instructions/MANIFEST.yaml b/paycraft-instructions/MANIFEST.yaml index 6adc1b56..d1907f1b 100644 --- a/paycraft-instructions/MANIFEST.yaml +++ b/paycraft-instructions/MANIFEST.yaml @@ -5,7 +5,7 @@ schema_version: 1 corpus_root: workspaces/mbs/PayCraft/source/PayCraft/paycraft-instructions sdk_repo_path: workspaces/mbs/PayCraft/source/PayCraft sdk_version: 2.4.6 # bumped by the fold; consumed by the D12 corpus-vs-artifact gate -sdk_head_commit: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +sdk_head_commit: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b documents: - PUBLIC_API.md - WIRING_CONTRACTS.md diff --git a/paycraft-instructions/PAYWALL_CUSTOMIZATION.md b/paycraft-instructions/PAYWALL_CUSTOMIZATION.md index 59196e94..8accd5f0 100644 --- a/paycraft-instructions/PAYWALL_CUSTOMIZATION.md +++ b/paycraft-instructions/PAYWALL_CUSTOMIZATION.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # PAYWALL_CUSTOMIZATION.md — the two supported paywall paths @@ -32,6 +32,24 @@ The host renders a PayCraft composable; everything visual comes from `SuiteConfi | `PayCraftPlanSection(style, theme, header)` | inline | subscription section for a settings screen — status, manage, restore (five styles; see below) | | `PayCraftCheckoutSuccessSheet` / `…OrPaywall` | sheet | post-purchase confirmation | | `ProductList(…)` | list | plan stack on its own | +| `PayCraftStoreSection(modifier)` | inline | the CONSUMABLE store — coin/credit packs, prices, and checkout, all the SDK's | +| `PayCraftProductGate` | wrapper | gate on OWNING a one-time product rather than on a subscription | +| `PayCraftBalance` | inline | the host's unit balance, where the host wants it | + +**`PayCraftStoreSection` takes no purchase callback, deliberately.** It calls +`PayCraftRepository.checkout` itself. A host-callback surface shipped once with a dead upgrade row: +it reported `clickable=true`, its handler ran, its golden matched, its logic tests passed — and only +an on-device render caught that nothing happened. A surface that owns its own presentation cannot +fail that way. + +It lists only the non-subscription catalogue, because a store that offers a plan beside a coin pack +invites a user to buy a subscription they may already hold. It also shows **no per-row balance**: a +pack's balance is the balance of the currency it GRANTS, not of the pack sku, and only the host +knows how to present its own currency — render `PayCraftBalance` (or read `repository.balances`) +wherever that belongs in your chrome. + +An empty render ("No products available") is a CONFIGURATION state, not an error — a tenant may sell +only subscriptions. If you expect packs and see that message, start at FAILURE_MODES F53/F54. The three gates replace `PayCraftPremiumGuard`, `PayCraftPremiumGuardInline` and `PayCraftPaywallGuardScreen`, all now `@Deprecated` — see PUBLIC_API.md § Gates. A guard renders a diff --git a/paycraft-instructions/PROVIDERS_AND_STORES.md b/paycraft-instructions/PROVIDERS_AND_STORES.md index 998969d6..8b938a2e 100644 --- a/paycraft-instructions/PROVIDERS_AND_STORES.md +++ b/paycraft-instructions/PROVIDERS_AND_STORES.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # PROVIDERS_AND_STORES.md — checkout lanes, provider adapters, store product sync @@ -141,9 +141,46 @@ product is only fully configured when all three agree: | Layer | Field | |---|---| -| PayCraft dashboard | `ProductDto.sku`, `type`, `interval`, `basePriceCents`/`baseCurrency`, and a routing rule naming this platform's primary provider | -| Google Play Console | a subscription/base-plan whose id equals the resolved `storeBinding.productId` | -| App Store Connect | a subscription whose id equals the resolved `storeBinding.productId` | +| PayCraft dashboard | `ProductDto.sku`, `type`, `interval`, `basePriceCents`/`baseCurrency`, `grant_quantity` (consumables), and a routing rule naming this platform's primary provider | +| Google Play Console | a subscription/base-plan, or a ONE-TIME PRODUCT, whose id equals the resolved `storeBinding.productId` | +| App Store Connect | a subscription, or an in-app purchase, whose id equals the resolved `storeBinding.productId` | + +### Consumables are a different RESOURCE in both stores + +A subscription and a coin pack are not two shapes of one store object — they live at different +endpoints, with different bodies and different activation rules. Getting this wrong fails in the +quiet direction: the product exists, the sync says synced, nobody can buy it. + +**Google Play.** One-time products are `monetization.onetimeproducts`. The legacy `inappproducts` +resource is RETIRED — every call answers `403 PERMISSION_DENIED: "Please migrate to the new +publishing API"`, which reads like a credentials problem and is not. Three things bite: + +- **Path casing is inconsistent in Google's own API.** `get`/`list` are served at `oneTimeProducts` + (camelCase); `patch` at `onetimeproducts` (all lowercase). The wrong spelling returns an HTML 404, + so it reads as "resource does not exist" rather than as a typo. +- **There is no `insert`.** Creation is `patch` + `allowMissing=true`, which makes the write + naturally idempotent. `updateMask` and `regionsVersion.version` are both required. +- **Creation does not make it sellable.** `purchaseOptions[].state` is OUTPUT-ONLY; the product + lands in `DRAFT` and only `purchaseOptions:batchUpdateStates` with an + `activatePurchaseOptionRequest` flips it to `ACTIVE`. A pack can sit in Play Console with correct + prices in every region, report `play_product_id` written back, and still be invisible. + +Set `buyOption.legacyCompatible: true` on exactly one purchase option. Purchase options are a newer +Play Billing concept, and a client querying the way most shipped apps do gets nothing back for a +product without one. Also send `newRegionsConfig` (USD + EUR): without it a market Play launches +later inherits no price and silently stops being purchasable there. + +Prices are a `Money` of `{currencyCode, units, nanos}` — NOT the legacy `priceMicros` string. + +**App Store Connect.** In-app purchases are `/v2/inAppPurchases`, separate from `/v1/subscriptions`. +`inAppPurchaseType` is **IMMUTABLE after creation**: a coin pack created as `NON_CONSUMABLE` can be +bought exactly once per Apple ID, forever, and the only remedy is a new product under a new id. A +newly created IAP also sits in `MISSING_METADATA` until a review screenshot is attached and it is +submitted with an app version. + +**Both stores:** a freshly created native product is normally NOT yet purchasable — Play needs a +published release, Apple needs review metadata. A sync that reports success without saying so is +reporting what it asked for, not what the storefront will honour. Failure signature per layer: diff --git a/paycraft-instructions/PUBLIC_API.md b/paycraft-instructions/PUBLIC_API.md index 2d4b1dfc..1c854f17 100644 --- a/paycraft-instructions/PUBLIC_API.md +++ b/paycraft-instructions/PUBLIC_API.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # PUBLIC_API.md — PayCraft SDK public integration surface @@ -466,6 +466,101 @@ wire a gate; existing ones migrate. Do not wire a new integration to a guard. `paycraft_subscribe_button`, …) — assert against these rather than against visible text, which is tenant-configurable and localized. +## Consumables — one-time packs that grant units + +A CONSUMABLE is a product bought repeatedly that grants a quantity into a balance the host spends +(a coin pack, a credit bundle). It is the second shape of product beside the recurring plan, and it +is wired differently end to end: a subscription grants ACCESS which the SDK answers with +`isPremium`, while a consumable grants UNITS which only the host can store, because only the host +knows what they mean. + +### The catalogue splits in two + +```kotlin +PayCraft.plans // the WHOLE catalogue +PayCraft.products // isConsumable || interval == "lifetime" — the STORE half +PayCraft.subscriptionPlans // !isConsumable — the PAYWALL half +``` + +`plans` is the full list; the two subsets partition it by what the surface is allowed to sell. Use +`subscriptionPlans` for anything that picks a plan by rank — a paywall taking `minByOrNull { rank }` +over the unfiltered list can offer a coin pack as the "upgrade" a free user is shown. + +On `BillingPlan`: `isConsumable: Boolean` and `grantQuantity: Int` (units ONE purchase grants; `0` +for every other type). `isConsumable` is carried explicitly rather than derived, because `interval` +collapses a null to `"lifetime"` — which makes a consumable indistinguishable from a lifetime +unlock, and the two are FINISHED with different store calls. + +### Where purchased units go — `ConsumableGrantSink` + +The host states one thing: how to credit its own ledger. Everything else is the SDK's. + +```kotlin +public fun interface ConsumableGrantSink { + public suspend fun credit(sku: String, quantity: Int, grantId: String): Boolean +} +``` + +Bound ONCE, beside `identity`, as a `Scope.() -> ConsumableGrantSink` so the lambda can `get()` the +host's own database: + +```kotlin +includes( + payCraft( + apiKey = …, testApiKey = …, + identity = { get().session.map { it.user?.email } }, + onUnitsGranted = { + val dao = get().ledgerDao + ConsumableGrantSink { sku, quantity, grantId -> + dao.insert(LedgerEntry(id = grantId, amount = quantity, /* … */)) + true + } + }, + ), +) +``` + +Contract, and each clause is load-bearing: + +- **`grantId` is a STABLE id for this grant.** Credit idempotently on it — use it as the PRIMARY KEY + of the host's ledger row and insert with `IGNORE`-on-conflict. The sink can be invoked again for + the same grant after a crash or a provider redelivery. +- **Return `true` only when the credit is DURABLY committed.** Returning `false` (or throwing) keeps + the grant pending and the next drain retries it. The SDK will not drop it. +- **The host never chooses the amount.** It transfers what the server granted. A client that could + decide the quantity is minting currency people pay real money for. +- **Omit `onUnitsGranted` and nothing drains.** An app with no consumables pays nothing for this. + +### What the SDK does on its own + +Given the sink, the SDK owns: which products are consumable, noticing a server-side balance, +debiting it with a caller-owned idempotency key, and replaying after a crash. It drains at init and +is cheap when there is nothing outstanding (one balance read per consumable sku). + +The transfer is two writes against systems that cannot share a transaction, so the intent is +persisted BEFORE the server is touched — credit-then-debit double-credits on retry, debit-then-credit +loses the purchase. Every step is then safe to repeat. + +Do NOT re-implement this in the host. The first consumer wrote 118 lines to do it and every one of +them was an invariant of the SDK. + +### Headless surface (`PayCraftRepository`) + +```kotlin +val balances: StateFlow> +suspend fun refreshBalance(sku: String): Int +suspend fun spend(sku: String, quantity: Int, idempotencyKey: String): Result +``` + +`spend` returns the server's new balance. Insufficient funds is `Result.failure`, NOT an exception — +a shopper short on units is an ordinary outcome the host renders, not a crash. A rejected spend must +not move the balance. Pass a caller-owned `idempotencyKey` so a retry after a timeout replays +instead of charging twice. + +Hosts that render their own currency read `balances` (or `PayCraftBalance`); `PayCraftStoreSection` +deliberately shows no per-row balance, because a pack's balance is the balance of the currency it +grants, not of the pack sku. + ## What an integrator must never do - Call a payment provider directly. The app talks only to Supabase; webhooks keep Supabase in sync. @@ -475,6 +570,10 @@ tenant-configurable and localized. - Hand-write a repository / store / API wrapper around this SDK. Inject `PayCraftRepository` instead — that wrapper is the 670 LOC the facade exists to delete. - Re-derive test/live in the host from a key prefix or build config. `PayCraft.mode` owns it. +- Credit a consumable balance locally, or let the host decide the granted quantity. The server is + the only authority on what a purchase granted. +- Re-implement the consumable drain. Bind `onUnitsGranted` and let the SDK own the rest. +- Pick a plan by rank out of `plans`. Use `subscriptionPlans` — `plans` now contains coin packs. --- diff --git a/paycraft-instructions/WEBHOOKS.md b/paycraft-instructions/WEBHOOKS.md index b092c9c4..dcdff291 100644 --- a/paycraft-instructions/WEBHOOKS.md +++ b/paycraft-instructions/WEBHOOKS.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # WEBHOOKS.md — endpoints, verification discipline, reachability probe @@ -58,6 +58,28 @@ one reconciled record), `receipt-validate.ts` (replay guard), `play-jwt.ts` + `a provider-specific `*ToCanonical` mapper, so the same subscription never produces two competing rows with different vocabularies. +## Consumable purchases — the grant path + +A one-time purchase does not reconcile an entitlement; it CREDITS A BALANCE. Every provider funnels +into one shared handler rather than per-provider grant logic, because the only provider-specific +facts are "what did they call the sku" and "what is the stable transaction id". How much to grant +belongs to the CATALOGUE, and six copies of that decision is six chances to disagree. + +- **The amount never comes from the payload.** It is read from the catalogue's `grant_quantity` by + sku. A webhook body is attacker-adjacent on a self-hosted deployment and, more mundanely, goes + stale after a catalogue edit. +- **The idempotency key is `provider:stableTxnId`.** Providers redeliver; a redelivery is normal + traffic, not a fault. The grant absorbs duplicates and still returns the current balance, so the + caller cannot tell a replay from a first delivery. A per-call random key would grant again every + time. The stable id is Stripe's payment_intent, Razorpay's payment id, Play's purchaseToken, + Apple's originalTransactionId. +- **Attribution is per-APP, never per-product.** Two tenants can both sell a sku called + `coins_550`, so resolving on the product id alone either errors on multiple rows or credits the + wrong app's ledger. Store lanes resolve the tenant from the app identifier (Play `packageName` / + Apple `bundleId`); web PSPs carry the tenant in the webhook URL path. +- **An unattributable grant is REFUSED, not guessed.** Money the buyer paid and cannot spend is + found only via a support ticket, so each refusal names exactly what was missing. + ### `register-play-purchase` contract ``` diff --git a/paycraft-instructions/WIRING_CONTRACTS.md b/paycraft-instructions/WIRING_CONTRACTS.md index 7985e684..c24a8c36 100644 --- a/paycraft-instructions/WIRING_CONTRACTS.md +++ b/paycraft-instructions/WIRING_CONTRACTS.md @@ -1,4 +1,4 @@ -example-provenance: f7c1ee26733dbc28e3e1528c2456dbfe43946a04 +example-provenance: d17c43b42c05b30b9029e3cbcbbfa314a8bd372b # WIRING_CONTRACTS.md — DI, initialization order, and the repository seam @@ -57,6 +57,30 @@ Platform-specific pieces are additive Koin modules layered on top, never a secon | `CouponClient` | | | | `PayCraftPaywallViewModel` | `viewModelOf` | **`internal`** — the bundled paywall's own MVI engine | +### The two things `payCraft(...)` takes from the host + +Everything above is wired FOR the consumer. The host supplies exactly two facts, because only it +can know them — both as `Scope.()`-receiver lambdas so they may `get()` the app's own bindings: + +| Parameter | What the host states | Why it cannot be inferred | +|---|---|---| +| `identity: Scope.() -> Flow` | the signed-in email | The SDK cannot know the app's auth session. It reads ONLY the address — no profile mapping, no second mapper competing with the app's own | +| `onUnitsGranted: (Scope.() -> ConsumableGrantSink)?` | how to credit the app's OWN ledger | Units mean something only the host defines. Omit it and nothing drains — an app without consumables pays nothing | + +`onUnitsGranted` is a `Scope.() -> sink` rather than a bare sink for the same reason `identity` is: +the credit lands in the host's storage, so the lambda has to reach the host's database. Resolve the +DAO once in the outer lambda and let the returned sink close over it — the SDK invokes the sink off +the Koin thread. + +```kotlin +includes(payCraft(apiKey = …, testApiKey = …, identity = { … }, onUnitsGranted = { … })) +``` + +**Do not hand-write a bootstrap around this.** The one-call form replaced a 74-line +`PayCraftBootstrap` in the first consumer (init-before-Koin ordering, an eager `createdAtStart` +single, a lambda-wrapped repository lookup, `distinctUntilChanged` + blank-filtering on the identity +stream) and later a 118-line top-up manager. Each was an invariant of the SDK living in the app. + **The qualifier matters.** Because the `SupabaseClient` is registered under `named("paycraft")`, an app that already binds its own unqualified `SupabaseClient` keeps it. Do not "simplify" by dropping the qualifier — that is how a consumer's auth session ends up on PayCraft's diff --git a/scripts/billing-canary.sh b/scripts/billing-canary.sh new file mode 100755 index 00000000..899c723f --- /dev/null +++ b/scripts/billing-canary.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# billing-canary.sh — "can a customer actually pay right now?", answered on a schedule. +# +# WHY THIS EXISTS +# Every check that existed before this one answered a different question: +# cloud-smoke.yml — does the marketing site return 200? (and it had no schedule at all) +# /api/v1/readiness — is the config shaped right, when someone asks +# webhook-health — is one tenant's config shaped right, when someone asks +# deploy.sh phase 6 — did the deploy land +# None of them answers "is the purchase path working", and none of them ran unattended. A purchase +# path breaks between deploys — a key rotates, a store product is deactivated, a webhook endpoint +# stops resolving — and every check above stays green through all three. +# +# THE HONEST LIMIT, STATED UP FRONT +# A real store purchase CANNOT be automated. Google Play and App Store sandbox purchases need a +# device and a signed-in test account; there is no API that completes one. So this script proves +# the purchase path to different depths per provider, and SAYS WHICH: +# +# L1 config every provider — readiness says it can transact live +# L2 delivery every provider — webhooks have not gone silent or started failing (migration 165) +# L3 chain PSPs only — a real test-mode charge traverses provider -> webhook -> +# entitlement -> is_premium, then is cleaned up +# +# Claiming L3 for Play/App Store would be a lie, and a canary that lies is worse than none: it +# converts an unknown into a false assurance. Store lanes are device-verified by +# `scripts/paycraft-device-verify.sh`, and this script reports them as L1+L2 only. +# +# EXIT CODES — designed for a scheduler, so the failure is in the status, not just the log +# 0 all applicable levels pass +# 1 a purchase path is BROKEN (readiness red, provider failing, chain assertion failed) +# 2 the canary could not run (missing credential, unreachable API) — NOT the same as healthy, +# and deliberately distinct from 1 so "we are blind" never reads as "we are fine" +set -uo pipefail + +FAIL=0; BLOCKED=0 +say() { printf ' %s\n' "$*"; } +hdr() { printf '\n── %s ──\n' "$*"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } +bad() { printf ' \033[31m✗\033[0m %s\n' "$*"; FAIL=1; } +blok() { printf ' \033[33m⚠\033[0m %s\n' "$*"; BLOCKED=1; } + +BASE="${PAYCRAFT_BASE_URL:-https://paycraft.mobilebytesensei.com}" +PCSK="${PAYCRAFT_ACCOUNT_KEY:-}" + +# Connection: either a full URL, or standard libpq PG* vars. Supporting PG* is not a convenience — +# it is the only way to point this at a database WITHOUT a credential-bearing string on the command +# line or in a process listing, which is what `secrets-value-egress-guard` exists to prevent. psql +# reads PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE itself, so `psql ""` with them set is correct. +DB_URL="${PAYCRAFT_DB_URL:-}" +if [ -n "$DB_URL" ]; then HAVE_DB=1 +elif [ -n "${PGHOST:-}" ]; then HAVE_DB=1 +else HAVE_DB=0 +fi + +# `psql ""` passes an EMPTY dbname and fails; libpq only falls through to PG* when the argument is +# absent entirely. So the connection target is appended conditionally rather than always. +q() { + if [ -n "$DB_URL" ]; then psql "$DB_URL" "$@"; else psql "$@"; fi +} + +printf '═══ PayCraft billing canary — %s ═══\n' "$BASE" + +# ── L1: config truth ───────────────────────────────────────────────────────────────────────────── +# Delegates to /api/v1/readiness rather than re-deriving per-provider rules. That endpoint already +# encodes which rows need human work (store test mode cannot be enabled through any API), so a +# second implementation here would drift from it — and the half that drifts is the half that lies. +hdr "L1 · provider config can transact" +if [ -z "$PCSK" ]; then + blok "PAYCRAFT_ACCOUNT_KEY unset — cannot call /api/v1/readiness (set the pcsk_ account key)" +else + R=$(curl -fsS -m 30 -H "Authorization: Bearer $PCSK" "$BASE/api/v1/readiness" 2>&1) || R="" + if [ -z "$R" ]; then + blok "/api/v1/readiness unreachable or refused the key" + else + # Count rows that are not live-ready. Keep the jq surface small: the endpoint's shape is the + # dashboard's contract, not this script's, so match loosely and fail loudly if nothing matched. + NOT_READY=$(printf '%s' "$R" | jq -r '[.. | objects | select(has("live_ready")) | select(.live_ready==false)] | length' 2>/dev/null || echo "?") + TOTAL=$(printf '%s' "$R" | jq -r '[.. | objects | select(has("live_ready"))] | length' 2>/dev/null || echo "?") + if [ "$TOTAL" = "?" ] || [ "$TOTAL" = "0" ]; then + blok "readiness returned no live_ready rows — shape changed? not treating as healthy" + elif [ "$NOT_READY" = "0" ]; then + ok "all $TOTAL provider rows live-ready" + else + bad "$NOT_READY/$TOTAL provider rows NOT live-ready — run /idea-paycraft to reconcile" + printf '%s' "$R" | jq -r '.. | objects | select(.live_ready==false) | " · \(.provider // "?"): \(.reason // .manual_steps // "see readiness")"' 2>/dev/null | head -8 + fi + fi +fi + +# ── L2: delivery truth ─────────────────────────────────────────────────────────────────────────── +# Reads migration 165's verdicts. `insufficient-baseline` is reported but NOT failed: at this +# deployment's volume (4 active subscriptions) a thin baseline is the normal case, and failing on +# it would make the canary cry wolf daily until someone muted it. +hdr "L2 · webhook delivery has not stopped" +if [ "$HAVE_DB" = "0" ]; then + blok "no database connection (PAYCRAFT_DB_URL or PG* env) — cannot read canary verdicts" +else + V=$(q -At -F'|' -c \ + "SELECT verdict, count(*) FROM billing_canary_webhook_verdicts() GROUP BY 1 ORDER BY 1;" 2>&1) || V="" + if [ -z "$V" ]; then + blok "could not query billing_canary_webhook_verdicts() — is migration 165 applied?" + else + while IFS='|' read -r verdict n; do + [ -z "$verdict" ] && continue + case "$verdict" in + ok) ok "$n provider(s) delivering normally" ;; + insufficient-baseline) say "⊘ $n provider(s) too quiet to judge (not a failure at this volume)" ;; + failing) bad "$n provider(s) FAILING deliveries" ;; + silent) bad "$n provider(s) went SILENT vs their own cadence" ;; + never-seen) bad "$n provider(s) configured but NEVER delivered a webhook" ;; + *) say "? $n provider(s) verdict=$verdict" ;; + esac + done <<< "$V" + # Name the broken ones; a count alone is not actionable at 3am. + q -At -F' · ' -c \ + "SELECT provider, verdict, detail FROM billing_canary_webhook_verdicts() + WHERE verdict IN ('failing','silent','never-seen') LIMIT 10;" 2>/dev/null \ + | sed 's/^/ /' + fi +fi + +# ── L3: chain truth (PSP only) ─────────────────────────────────────────────────────────────────── +# The only level that proves money moves. Needs a dedicated canary tenant holding Stripe TEST +# credentials, because the platform key is deliberately absent from the vault (confirmed on the +# 2026-10-22 deploy: `paycraft-stripe-platform-secret-key : not in vault`). A canary must never be +# the reason a live platform secret gets provisioned, so this uses tenant-scoped test credentials +# like any other tenant — and reports a BLOCKED status, not a pass, until one is configured. +hdr "L3 · a test-mode charge traverses the whole chain (PSP only)" +if [ -z "${PAYCRAFT_CANARY_TENANT_ID:-}" ]; then + blok "PAYCRAFT_CANARY_TENANT_ID unset — chain unverified. One-time setup:" + say " 1. create a tenant named 'canary' in the dashboard" + say " 2. attach Stripe with TEST credentials (never live — this charges)" + say " 3. create a \$1 test product, note its sku" + say " 4. set PAYCRAFT_CANARY_TENANT_ID + PAYCRAFT_CANARY_SKU in CI" + say " Until then L3 is BLOCKED, which exits 2 — never a silent pass." +else + if [ "$HAVE_DB" = "0" ]; then + blok "no database connection (PAYCRAFT_DB_URL or PG* env) — cannot assert the chain landed" + else + # Does the chain show evidence of a recent synthetic purchase? The charge itself is driven by + # the provider's own test-mode API (see STRIPE_TEST_KEY below); what this asserts is the part + # that actually breaks: did the webhook land AND did the entitlement become readable. + if [ -z "${STRIPE_TEST_KEY:-}" ]; then + blok "STRIPE_TEST_KEY unset — cannot originate a test charge" + else + IDEM="canary-$(date -u +%Y%m%dT%H%M%SZ)" + CUST=$(curl -fsS -m 30 https://api.stripe.com/v1/customers \ + -u "$STRIPE_TEST_KEY:" -d "email=canary+$IDEM@paycraft.test" 2>/dev/null \ + | jq -r '.id // empty') + if [ -z "$CUST" ]; then + blok "Stripe test API refused — key invalid or not a test key" + else + ok "stripe test customer created ($CUST)" + # Deliberately stops here rather than completing a subscription: a subscription needs a + # price id that only the canary tenant's own catalogue defines, and inventing one here + # would test Stripe rather than PayCraft. The sku comes from config, so when + # PAYCRAFT_CANARY_SKU is set the charge is driven through PayCraft's own checkout. + if [ -z "${PAYCRAFT_CANARY_SKU:-}" ]; then + blok "PAYCRAFT_CANARY_SKU unset — charge not driven through PayCraft checkout" + else + CO=$(curl -fsS -m 45 -X POST "$BASE/functions/v1/checkout-initiate" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${PAYCRAFT_CANARY_API_KEY:-$PCSK}" \ + -d "{\"sku\":\"$PAYCRAFT_CANARY_SKU\",\"mode\":\"test\",\"idempotency_key\":\"$IDEM\"}" 2>&1) + if printf '%s' "$CO" | grep -qiE '"(url|checkout_url|session)"'; then + ok "checkout-initiate produced a session for $PAYCRAFT_CANARY_SKU" + say "⊘ completion requires the hosted page; webhook arrival asserted by L2 cadence" + else + bad "checkout-initiate did not return a session: $(printf '%s' "$CO" | head -c 160)" + fi + fi + curl -fsS -m 20 -X DELETE "https://api.stripe.com/v1/customers/$CUST" -u "$STRIPE_TEST_KEY:" >/dev/null 2>&1 \ + && ok "test customer cleaned up" || blok "could not delete test customer $CUST — clean up manually" + fi + fi + fi +fi + +# ── verdict ────────────────────────────────────────────────────────────────────────────────────── +printf '\n═══ ' +if [ "$FAIL" = "1" ]; then + printf 'CANARY FAILED — a purchase path is broken ═══\n'; exit 1 +elif [ "$BLOCKED" = "1" ]; then + printf 'CANARY BLOCKED — checks could not run (not the same as healthy) ═══\n'; exit 2 +else + printf 'CANARY PASS — every applicable level green ═══\n'; exit 0 +fi diff --git a/supabase/functions/_shared/__tests__/consumable-handler.test.ts b/supabase/functions/_shared/__tests__/consumable-handler.test.ts new file mode 100644 index 00000000..a4b1ae0f --- /dev/null +++ b/supabase/functions/_shared/__tests__/consumable-handler.test.ts @@ -0,0 +1,372 @@ +// supabase/functions/_shared/__tests__/consumable-handler.test.ts +// +// The provider-agnostic consumable grant. Every assertion here is about a buyer who HAS ALREADY +// BEEN CHARGED, which is what makes the refusal paths matter more than the happy one: a grant that +// quietly does nothing is money taken for goods never delivered, and the only detector is a +// support ticket. +// +// The two properties worth protecting: +// +// 1. the AMOUNT comes from the catalogue, never the webhook payload — a provider-supplied +// quantity would let a stale or forged number into the ledger; +// 2. every refusal is NAMED, so an operator reading logs learns which purchase failed and why, +// rather than seeing silence. +// +// Strategy mirrors subscription-handler.audit.test.ts: stub fetch, assert on the RPC traffic. +// +// Run: deno test --allow-env --allow-net supabase/functions/_shared/__tests__/consumable-handler.test.ts + +Deno.env.set("SUPABASE_URL", "http://stub.local"); +Deno.env.set("SUPABASE_SERVICE_ROLE_KEY", "stub-service-role-key"); + +import { assert, assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; + +// DYNAMIC import: supabase-admin.ts builds its client at module top level, and static imports are +// hoisted above the Deno.env.set calls above. Same reason subscription-handler.audit.test.ts does it. +const { handleConsumablePurchase } = await import("../consumable-handler.ts"); + +interface Captured { + url: string; + body: Record | null; +} + +const TENANT = "ba973ad0-8788-4c0f-89c8-1ff9533fa79f"; +const SUBJECT = "11111111-2222-3333-4444-555555555555"; + +/** + * @param product the tenant_products row the catalogue lookup returns (null = not found) + */ +function stubFetch(product: Record | null, opts: { grantBalance?: number } = {}) { + const calls: Captured[] = []; + globalThis.fetch = ((url: string | URL, init?: RequestInit) => { + const u = String(url); + let body: Record | null = null; + try { + body = init?.body ? JSON.parse(String(init.body)) : null; + } catch { + body = null; + } + calls.push({ url: u, body }); + + if (u.includes("/rest/v1/tenant_products")) { + return Promise.resolve( + new Response(JSON.stringify(product), { status: 200, headers: { "content-type": "application/json" } }), + ); + } + if (u.includes("entitlement_subject_resolve")) { + return Promise.resolve( + new Response(JSON.stringify(SUBJECT), { status: 200, headers: { "content-type": "application/json" } }), + ); + } + if (u.includes("entitlement_balance_grant")) { + return Promise.resolve( + new Response(JSON.stringify(opts.grantBalance ?? 550), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.resolve(new Response("{}", { status: 200, headers: { "content-type": "application/json" } })); + }) as typeof fetch; + return calls; +} + +const baseEvent = { + tenantId: TENANT, + email: "buyer@example.com", + sku: "cappy_ripples_550", + stableTxnId: "pi_123", + provider: "stripe", +}; + +Deno.test("grants the CATALOGUE quantity, not anything from the payload", async () => { + const calls = stubFetch({ sku: "cappy_ripples_550", type: "consumable", grant_quantity: 550 }); + + const res = await handleConsumablePurchase(baseEvent); + + assertEquals(res.granted, true); + assertEquals(res.quantity, 550); + const grant = calls.find((c) => c.url.includes("entitlement_balance_grant")); + assert(grant, "expected a grant RPC call"); + assertEquals(grant!.body?.p_quantity, 550); + assertEquals(grant!.body?.p_sku, "cappy_ripples_550"); + assertEquals(grant!.body?.p_subject_id, SUBJECT); +}); + +Deno.test("keys idempotency on provider + transaction id so a redelivery cannot double-grant", async () => { + const calls = stubFetch({ sku: "cappy_ripples_550", type: "consumable", grant_quantity: 550 }); + + await handleConsumablePurchase(baseEvent); + + const grant = calls.find((c) => c.url.includes("entitlement_balance_grant")); + // Provider-prefixed: two providers can mint the same opaque id, and the ledger's uniqueness is + // per tenant — without the prefix one provider's retry could absorb another's real purchase. + assertEquals(grant!.body?.p_idempotency_key, "stripe:pi_123"); + assertEquals(grant!.body?.p_stable_txn_id, "pi_123"); +}); + +Deno.test("refuses a sku whose grant_quantity is zero, and says the buyer was charged", async () => { + const calls = stubFetch({ sku: "broken_pack", type: "consumable", grant_quantity: 0 }); + + const res = await handleConsumablePurchase({ ...baseEvent, sku: "broken_pack" }); + + assertEquals(res.granted, false); + assertEquals(res.quantity, 0); + assert(res.reason!.includes("would receive nothing"), `unhelpful reason: ${res.reason}`); + // Crucially: no grant attempted. Granting 0 would report success for a no-op. + assert(!calls.some((c) => c.url.includes("entitlement_balance_grant"))); +}); + +Deno.test("refuses a subscription routed here by mistake", async () => { + const calls = stubFetch({ sku: "cappy_plus_annual", type: "subscription", grant_quantity: 0 }); + + const res = await handleConsumablePurchase({ ...baseEvent, sku: "cappy_plus_annual" }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("not a consumable")); + assert(!calls.some((c) => c.url.includes("entitlement_balance_grant"))); +}); + +Deno.test("refuses a sku that is not in the tenant's catalogue", async () => { + stubFetch(null); + + const res = await handleConsumablePurchase({ ...baseEvent, sku: "unknown_sku" }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("not in this tenant's catalogue")); +}); + +Deno.test("refuses without a stable transaction id rather than inventing a key", async () => { + const calls = stubFetch({ sku: "cappy_ripples_550", type: "consumable", grant_quantity: 550 }); + + const res = await handleConsumablePurchase({ ...baseEvent, stableTxnId: null }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("stable transaction id")); + // A random key would make EVERY redelivery grant the pack again — strictly worse than refusing. + assert(!calls.some((c) => c.url.includes("entitlement_balance_grant"))); +}); + +Deno.test("refuses an unattributed grant (no tenant, no identity)", async () => { + stubFetch({ sku: "cappy_ripples_550", type: "consumable", grant_quantity: 550 }); + + const noTenant = await handleConsumablePurchase({ ...baseEvent, tenantId: null }); + assertEquals(noTenant.granted, false); + assert(noTenant.reason!.includes("tenant")); + + const noIdentity = await handleConsumablePurchase({ ...baseEvent, email: null }); + assertEquals(noIdentity.granted, false); + assert(noIdentity.reason!.includes("identity")); +}); + + +// ── Store lanes ──────────────────────────────────────────────────────────────────────────────── +// +// Play's Pub/Sub topic and Apple's ASSN endpoint are per-DEPLOYMENT, not per-tenant, so neither +// can pass a tenantId — `entitlement-reconcile` records `tenantId: null` for both. The catalogue +// row that owns the STORE product id is what supplies it, which is only possible because the +// product sync writes play_product_id / app_store_product_id back after creating the product. + +/** Store lookups resolve by store id and must return tenant_id, since the webhook has none. */ +function stubStoreFetch( + product: Record | null, + opts: { tenant?: Record | null; providerTenants?: Array<{ tenant_id: string }> } = {}, +) { + const calls: Captured[] = []; + globalThis.fetch = ((url: string | URL, init?: RequestInit) => { + const u = String(url); + let body: Record | null = null; + try { + body = init?.body ? JSON.parse(String(init.body)) : null; + } catch { + body = null; + } + calls.push({ url: u, body }); + if (u.includes("/rest/v1/tenant_providers")) { + // The sole-provider-tenant fallback maps over this — it must be an ARRAY. + return Promise.resolve( + new Response(JSON.stringify(opts.providerTenants ?? []), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (u.includes("/rest/v1/tenants")) { + const t = opts.tenant === undefined ? { id: TENANT } : opts.tenant; + return Promise.resolve( + new Response(JSON.stringify(t), { status: 200, headers: { "content-type": "application/json" } }), + ); + } + if (u.includes("/rest/v1/tenant_products")) { + return Promise.resolve( + new Response(JSON.stringify(product), { status: 200, headers: { "content-type": "application/json" } }), + ); + } + if (u.includes("entitlement_subject_resolve")) { + return Promise.resolve( + new Response(JSON.stringify(SUBJECT), { status: 200, headers: { "content-type": "application/json" } }), + ); + } + return Promise.resolve( + new Response(JSON.stringify(550), { status: 200, headers: { "content-type": "application/json" } }), + ); + }) as typeof fetch; + return calls; +} + +Deno.test("Play: derives the tenant from the store product id, with no tenantId on the event", async () => { + const calls = stubStoreFetch({ + tenant_id: TENANT, + sku: "cappy_ripples_550", + type: "consumable", + grant_quantity: 550, + }); + + const res = await handleConsumablePurchase({ + tenantId: null, // Pub/Sub topic is per-deployment — there is no tenant to pass + appIdentifier: "com.mobilebytesensei.cappy", + storeProductId: "cappy_ripples_550", + identity: { kind: "auth_user", value: "obfuscated-account-123" }, + stableTxnId: "purchase-token-abc", + provider: "google_play", + }); + + assertEquals(res.granted, true); + assertEquals(res.quantity, 550); + // Tenant comes from the APP, then the product is looked up INSIDE that tenant. + const tenantLookup = calls.find((c) => c.url.includes("/rest/v1/tenants")); + assert(tenantLookup, "expected a tenants lookup by app_identifier"); + assert(tenantLookup!.url.includes("app_identifier"), tenantLookup!.url); + const lookup = calls.find((c) => c.url.includes("tenant_products")); + assert(lookup!.url.includes("play_product_id"), `looked up by the wrong column: ${lookup!.url}`); + // Scoped to the resolved tenant — without this a sku shared by two tenants is ambiguous. + assert(lookup!.url.includes("tenant_id"), `product lookup not tenant-scoped: ${lookup!.url}`); + const grant = calls.find((c) => c.url.includes("entitlement_balance_grant")); + assertEquals(grant!.body?.p_tenant_id, TENANT); + assertEquals(grant!.body?.p_idempotency_key, "google_play:purchase-token-abc"); +}); + +Deno.test("App Store: resolves on app_store_product_id, not the Play column", async () => { + const calls = stubStoreFetch({ + tenant_id: TENANT, + sku: "cappy_ripples_550", + type: "consumable", + grant_quantity: 550, + }); + + await handleConsumablePurchase({ + tenantId: null, + appIdentifier: "com.mobilebytesensei.cappy", + storeProductId: "com.mobilebytesensei.cappy.cappyripples550", + identity: { kind: "auth_user", value: "app-account-token-xyz" }, + stableTxnId: "original-txn-999", + provider: "app_store", + }); + + const lookup = calls.find((c) => c.url.includes("tenant_products")); + assert( + lookup!.url.includes("app_store_product_id"), + `App Store must not resolve on the Play column: ${lookup!.url}`, + ); +}); + +Deno.test("a store account id is recorded as VERIFIED, unlike a typed-in email", async () => { + const calls = stubStoreFetch({ + tenant_id: TENANT, + sku: "cappy_ripples_550", + type: "consumable", + grant_quantity: 550, + }); + + await handleConsumablePurchase({ + tenantId: null, + appIdentifier: "com.mobilebytesensei.cappy", + storeProductId: "cappy_ripples_550", + identity: { kind: "auth_user", value: "obfuscated-account-123" }, + stableTxnId: "purchase-token-abc", + provider: "google_play", + }); + + const resolve = calls.find((c) => c.url.includes("entitlement_subject_resolve")); + // The store attested this id; an email typed into a checkout form is not attested, and the + // identity-link rules in migration 154 depend on that distinction. + assertEquals(resolve!.body?.p_verified, true); + assertEquals(resolve!.body?.p_kind, "auth_user"); +}); + +Deno.test("an unknown store product is refused, not granted against another tenant", async () => { + stubStoreFetch(null); + + const res = await handleConsumablePurchase({ + tenantId: null, + appIdentifier: "com.mobilebytesensei.cappy", + storeProductId: "not_in_any_catalogue", + identity: { kind: "auth_user", value: "obfuscated-account-123" }, + stableTxnId: "purchase-token-abc", + provider: "google_play", + }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("not in this tenant's catalogue")); +}); + + +Deno.test("an unknown app on a MULTI-tenant deployment is refused, never attributed", async () => { + // No tenant carries this app_identifier. With two tenants on the provider there is no safe + // guess, and crediting whichever one happened to match would put one app's paid purchase into + // another app's ledger — the exact failure per-tenant-app scoping exists to prevent. + const calls = stubStoreFetch( + { tenant_id: TENANT, sku: "x", type: "consumable", grant_quantity: 550 }, + { tenant: null, providerTenants: [{ tenant_id: TENANT }, { tenant_id: "other-tenant" }] }, + ); + + const res = await handleConsumablePurchase({ + tenantId: null, + appIdentifier: "com.someone.else", + storeProductId: "coins_550", + identity: { kind: "auth_user", value: "acct-1" }, + stableTxnId: "tok-1", + provider: "google_play", + }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("Ambiguous tenant"), res.reason); + assert(!calls.some((c) => c.url.includes("entitlement_balance_grant"))); +}); + +Deno.test("an un-backfilled app_identifier falls back to the SOLE provider tenant", async () => { + // Deliberate, and the same rule the web PSPs have always used: a self-hosted or single-app + // deployment that never set app_identifier should not have paid purchases rejected. It is only + // safe because it requires exactly one candidate — two is the ambiguous case above. + stubStoreFetch( + { tenant_id: TENANT, sku: "cappy_ripples_550", type: "consumable", grant_quantity: 550 }, + { tenant: null, providerTenants: [{ tenant_id: TENANT }] }, + ); + + const res = await handleConsumablePurchase({ + tenantId: null, + appIdentifier: "com.not.backfilled", + storeProductId: "cappy_ripples_550", + identity: { kind: "auth_user", value: "acct-1" }, + stableTxnId: "tok-2", + provider: "google_play", + }); + + assertEquals(res.granted, true); + assertEquals(res.quantity, 550); +}); + +Deno.test("a store event with no app identifier is refused", async () => { + stubStoreFetch({ tenant_id: TENANT, sku: "x", type: "consumable", grant_quantity: 550 }); + + const res = await handleConsumablePurchase({ + tenantId: null, + storeProductId: "coins_550", + identity: { kind: "auth_user", value: "acct-1" }, + stableTxnId: "tok-1", + provider: "google_play", + }); + + assertEquals(res.granted, false); + assert(res.reason!.includes("app identifier"), res.reason); +}); diff --git a/supabase/functions/_shared/consumable-handler.ts b/supabase/functions/_shared/consumable-handler.ts new file mode 100644 index 00000000..8d958530 --- /dev/null +++ b/supabase/functions/_shared/consumable-handler.ts @@ -0,0 +1,240 @@ +import { resolveInboundTenant } from "./inbound-tenant.ts"; +import { supabaseAdmin } from "./supabase-admin.ts"; + +/** + * Credit a CONSUMABLE / one-time purchase to the buyer's balance — the provider-agnostic + * counterpart to `handleSubscriptionEvent`. + * + * ## Why this exists + * + * Every piece of the consumable economy shipped except the one that moves the money: the ledger, + * the grant RPC, the client `balances`/`spend` surface and the store sync all existed, and + * `entitlement_balance_grant` had **zero callers**. A tenant could create a coin pack, sync it to + * Play, the App Store, Stripe and Razorpay, sell it — and the buyer's balance would never move, on + * any provider. Nothing errored; the money simply arrived and the goods never did. + * + * It is deliberately ONE shared function rather than per-provider logic. The provider differences + * that matter here are only "what did they call the sku" and "what is the stable transaction id"; + * the decision of how much to grant belongs to the CATALOGUE, not to a webhook. Six copies of that + * decision is six chances for one provider to grant a different amount than the others. + * + * ## The amount is never taken from the provider + * + * `grant_quantity` is read from `tenant_products` by sku. A webhook payload is attacker-adjacent + * for self-hosted deployments and, more mundanely, can be stale after a catalogue edit — so a + * provider-supplied quantity would let the wrong number into the ledger. The catalogue is the only + * authority on what a pack is worth (the same reason the client cannot choose it, AR-013). + * + * ## Idempotency + * + * Providers redeliver. `entitlement_balance_grant` absorbs a duplicate via + * `ON CONFLICT (tenant_id, idempotency_key) DO NOTHING` and still returns the current balance, so + * the caller cannot tell a redelivery from a first delivery — which is correct, because a webhook + * retry is normal traffic, not a fault. The key must therefore be stable per PURCHASE, which is + * what `stableTxnId` is for: Stripe's payment_intent, Razorpay's payment id, Play's purchaseToken, + * Apple's originalTransactionId. A per-call random key would grant every redelivery again. + */ +export interface ConsumablePurchaseEvent { + /** + * Tenant from the webhook URL path. NULL is refused for the web PSPs — an unattributed grant is + * unspendable. The STORE lanes are the exception: Play's Pub/Sub topic and Apple's ASSN endpoint + * are per-DEPLOYMENT, not per-tenant (which is why `entitlement-reconcile` records + * `tenantId: null` for both), so they pass [storeProductId] instead and the tenant is derived + * from the catalogue row that owns that store id. + */ + tenantId: string | null; + /** + * Buyer email — the identifier every web PSP carries. Sugar for `identity: {kind:"email"}`. + * Stores do not have one; they pass [identity] with the app's own user id instead. + */ + email?: string | null; + /** + * Explicit buyer identity, for providers that do not carry an email. + * + * `kind` is constrained by `entitlement_identities_kind_check` to + * `email | oauth | device | auth_user`. A store purchase arrives with the app-supplied account + * id — Play's `obfuscatedExternalAccountId`, Apple's `appAccountToken` — which is `auth_user`. + */ + identity?: { kind: "email" | "oauth" | "device" | "auth_user"; provider?: string | null; value: string }; + /** + * Catalogue sku as the provider knows it (Stripe `plan_id` metadata, Play productId, …). + * Either this OR [productId] must be present. + */ + sku?: string | null; + /** + * PayCraft product UUID, when that is what the provider carries instead of the sku — Razorpay + * payment links are created with `notes.paycraft_product_id`. Providers differ in which + * identifier survives the round trip, and forcing one would mean rewriting integrations that + * already work; resolving either here keeps that difference out of every webhook. + */ + productId?: string | null; + /** + * The id the STORE knows the product by (`tenant_products.play_product_id` / + * `.app_store_product_id`), written back by the product sync. + */ + storeProductId?: string | null; + /** + * The APP the purchase happened in — Play's `packageName`, Apple's `bundleId`. This is how a + * store purchase is attributed to a tenant, matching how the rest of the system scopes: + * `tenants.app_identifier` (migration 111) is owned by the app and read by both stores. + * + * Required for the store lanes, and it is NOT interchangeable with resolving on the product id + * alone: two tenants can both sell a sku called `coins_550`, so a product-only lookup either + * errors on multiple rows or silently attributes one tenant's purchase to another's ledger. + */ + appIdentifier?: string | null; + /** Stable per-purchase id — the idempotency anchor. */ + stableTxnId: string | null; + provider: string; + /** Provider event name, for the failure message only. */ + eventType?: string; +} + +export interface ConsumableGrantResult { + granted: boolean; + /** Units credited (the catalogue's grant_quantity), or 0 when nothing was granted. */ + quantity: number; + /** Balance after the grant, as the server computed it. */ + balance?: number; + /** Why nothing was granted. Present only when `granted` is false. */ + reason?: string; +} + +export async function handleConsumablePurchase( + event: ConsumablePurchaseEvent, +): Promise { + const { stableTxnId, provider } = event; + const sku = event.sku ?? null; + const productId = event.productId ?? null; + const storeProductId = event.storeProductId ?? null; + const appIdentifier = event.appIdentifier ?? null; + const identity = event.identity ?? + (event.email ? { kind: "email" as const, provider: null, value: event.email } : null); + + // Every one of these is a REFUSAL, not a silent skip. A grant that cannot be attributed is money + // the buyer paid and cannot spend, and the only way anyone finds out is a support ticket — so + // each failure names exactly what was missing. + if (!event.tenantId && !appIdentifier) { + return { + granted: false, + quantity: 0, + reason: "no tenant_id on the webhook URL and no app identifier to resolve one from", + }; + } + if (!identity) return { granted: false, quantity: 0, reason: "no buyer identity on the event" }; + if (!sku && !productId && !storeProductId) { + return { granted: false, quantity: 0, reason: "no sku or product id on the event" }; + } + if (!stableTxnId) { + // Without a stable id there is no safe idempotency key, and granting under a random one would + // credit the pack again on every redelivery. Refusing is the conservative failure. + return { granted: false, quantity: 0, reason: "no stable transaction id on the event" }; + } + + // The catalogue decides the amount — never the payload. + // Same resolver every inbound lane uses — path → app_identifier → sole provider tenant → + // self-hosted. This used to be an ad-hoc tenants lookup written only for this path, which is + // exactly the duplication that left the store SUBSCRIPTION lanes with no attribution at all. + let tenantId = event.tenantId ?? null; + if (!tenantId) { + try { + const resolved = await resolveInboundTenant({ + tenantIdFromPath: event.tenantId, + appIdentifier, + provider, + }); + tenantId = resolved.tenantId; + } catch (e) { + // Ambiguous attribution. Refused, never guessed — crediting the wrong app's ledger is worse + // than failing a webhook the provider will redeliver. + return { granted: false, quantity: 0, reason: (e as Error).message }; + } + } + if (!tenantId) { + return { + granted: false, + quantity: 0, + reason: `could not attribute this ${provider} purchase to an app — set tenants.app_identifier with tenant_app_identifier_set`, + }; + } + + let lookup = supabaseAdmin + .from("tenant_products") + .select("tenant_id, sku, grant_sku, type, grant_quantity") + .eq("tenant_id", tenantId); + if (storeProductId) { + const column = provider === "app_store" ? "app_store_product_id" : "play_product_id"; + lookup = lookup.eq(column, storeProductId); + } else if (sku) { + lookup = lookup.eq("sku", sku); + } else { + lookup = lookup.eq("id", productId); + } + const { data: product, error: productError } = await lookup.maybeSingle(); + const label = sku ?? storeProductId ?? productId; + + if (productError) { + return { granted: false, quantity: 0, reason: `catalogue lookup failed: ${productError.message}` }; + } + if (!product) { + return { granted: false, quantity: 0, reason: `${label} is not in this tenant's catalogue` }; + } + // A subscription landing here is a ROUTING bug in the caller, not a product to grant. Say so + // rather than crediting zero and reporting success. + if (product.type !== "consumable" && product.type !== "one_time") { + return { granted: false, quantity: 0, reason: `${label} is a ${product.type}, not a consumable` }; + } + const quantity = Number(product.grant_quantity ?? 0); + if (!(quantity > 0)) { + // The API refuses to CREATE such a product, so this is a row that predates that rule or was + // written directly to the database. Loud, because the buyer has already been charged. + return { + granted: false, + quantity: 0, + reason: `${label} has grant_quantity ${quantity} — the buyer was charged and would receive nothing`, + }; + } + + // Mint-or-find the buyer's entitlement subject. The same resolver the subscription path uses, so + // a customer who subscribes AND buys a pack is one subject with one balance, not two identities. + const { data: subjectId, error: subjectError } = await supabaseAdmin.rpc( + "entitlement_subject_resolve", + { + p_kind: identity.kind, + p_provider: identity.provider ?? null, + p_value: identity.value, + // A store account id is attested by the store, unlike an email typed into a checkout form. + p_verified: identity.kind === "auth_user" || identity.kind === "oauth", + }, + ); + if (subjectError || !subjectId) { + return { + granted: false, + quantity: 0, + reason: `could not resolve a subject for ${identity.kind}:${identity.value}: ${subjectError?.message ?? "no id returned"}`, + }; + } + + // Key on the provider AND its transaction id. Two providers could theoretically mint the same + // opaque id, and the ledger's uniqueness is per tenant — so the provider prefix is what keeps + // one provider's retry from absorbing another provider's genuine purchase. + const idempotencyKey = `${provider}:${stableTxnId}`; + + const { data: balance, error: grantError } = await supabaseAdmin.rpc("entitlement_balance_grant", { + p_tenant_id: product.tenant_id, + p_subject_id: subjectId, + // The CURRENCY the product credits, not the product. Several pack sizes of one currency share a + // grant_sku so their balances add up; without this, buying the 800 pack credits a balance the + // 300 pack never touches and the user's coins do not combine (migration 164). + p_sku: (product.grant_sku ?? "").trim() || product.sku, + p_quantity: quantity, + p_stable_txn_id: stableTxnId, + p_idempotency_key: idempotencyKey, + }); + + if (grantError) { + return { granted: false, quantity: 0, reason: `grant failed: ${grantError.message}` }; + } + + return { granted: true, quantity, balance: typeof balance === "number" ? balance : undefined }; +} diff --git a/supabase/functions/_shared/inbound-tenant.ts b/supabase/functions/_shared/inbound-tenant.ts new file mode 100644 index 00000000..50f12b7d --- /dev/null +++ b/supabase/functions/_shared/inbound-tenant.ts @@ -0,0 +1,110 @@ +import { supabaseAdmin } from "./supabase-admin.ts"; + +/** + * Resolve which APP (tenant) an inbound provider event belongs to. + * + * ## Why this is one function and not three + * + * Outbound traffic (the v1 API and MCP) is account-authenticated: the caller presents an account + * access token plus the app's api key, and `requireApiKey` refuses to act on an app the key does + * not reach. Inbound traffic has no key to carry — the PROVIDER calls us — so the app has to be + * recovered from the event itself. That is a real asymmetry and it cannot be removed. + * + * What CAN be removed is three different recoveries. Before this helper there were: + * + * 1. web PSPs — tenant in the URL path, with a sole-tenant fallback inlined in + * `handleSubscriptionEvent`; + * 2. the store lanes — nothing at all, so `entitlement-reconcile` recorded `tenantId: null` for + * every Play and App Store subscription; + * 3. store consumables — an ad-hoc `tenants.app_identifier` lookup written for the grant path. + * + * Three recoveries meant three places to get multi-tenant attribution wrong, and (2) was already + * wrong: a store subscription with a NULL tenant never matches + * `check_premium_with_device()`'s `tenant_id IS NOT DISTINCT FROM` filter, so `is_premium` reads + * false for a customer who has paid. Every inbound lane now resolves through here. + * + * ## Order, and why + * + * 1. **URL path** — explicit, and the only one that cannot be ambiguous. Always wins. + * 2. **App identifier** — Play's `packageName`, Apple's `bundleId`, matched against + * `tenants.app_identifier` (migration 111: owned by the app, read by both stores). This is the + * store lanes' real attribution, because their endpoints are per-DEPLOYMENT: one Pub/Sub topic + * and one ASSN URL serve every tenant. + * 3. **Sole provider tenant** — if exactly one tenant has this provider configured, attribute to + * it and warn. More than one is AMBIGUOUS and throws: writing a NULL-tenant row would silently + * deny access to a paying customer, and guessing would credit the wrong app's ledger. + * 4. **Null** — zero provider tenants is a genuine single-tenant self-hosted deployment, where + * NULL is correct. + * + * Resolving on the PRODUCT id is deliberately NOT one of these. Two tenants can both sell a sku + * called `coins_550`, so a product-only lookup either errors on multiple rows or puts one app's + * purchase into another app's ledger. + */ +export interface InboundTenantInput { + /** Tenant from the webhook URL path, when the endpoint is per-tenant. */ + tenantIdFromPath?: string | null; + /** Play `packageName` / Apple `bundleId` — the app the purchase happened in. */ + appIdentifier?: string | null; + /** Provider name, for the sole-tenant fallback and the error message. */ + provider: string; +} + +export interface InboundTenantResult { + tenantId: string | null; + /** How it was resolved — surfaced so a caller can log a weak attribution. */ + via: "path" | "app_identifier" | "sole_provider_tenant" | "self_hosted"; +} + +export async function resolveInboundTenant( + input: InboundTenantInput, +): Promise { + const { tenantIdFromPath, appIdentifier, provider } = input; + + if (tenantIdFromPath) return { tenantId: tenantIdFromPath, via: "path" }; + + if (appIdentifier) { + const { data, error } = await supabaseAdmin + .from("tenants") + .select("id") + .eq("app_identifier", appIdentifier) + .maybeSingle(); + if (error) { + throw new Error(`tenant lookup by app_identifier failed: ${error.message}`); + } + if (data?.id) return { tenantId: data.id as string, via: "app_identifier" }; + // Fall through rather than throw: a deployment that has not backfilled app_identifier yet can + // still be attributed by the sole-tenant rule below, which is strictly safer than failing a + // paid purchase outright. If that rule is ambiguous too, it throws there and names the fix. + console.warn( + `[inbound-tenant] no tenant has app_identifier ${appIdentifier} — falling back to the sole-${provider}-tenant rule. Set it with tenant_app_identifier_set.`, + ); + } + + const { data: provRows, error: provError } = await supabaseAdmin + .from("tenant_providers") + .select("tenant_id") + .eq("provider", provider); + if (provError) { + throw new Error(`tenant_providers lookup failed for ${provider}: ${provError.message}`); + } + // Array.isArray, not `?? []`: PostgREST answers an OBJECT on error and some transports hand back + // a non-array body, and `.map` on either throws a TypeError that surfaces as a 500 the provider + // then retries forever. The previous `(provRows ?? []).map(...)` had exactly that defect — it was + // the real cause of the long-standing AC-24 failure, which looked like a test bug and was not. + const rows: Array<{ tenant_id: string | null }> = Array.isArray(provRows) ? provRows : []; + const tenantIds = [...new Set(rows.map((r) => r.tenant_id).filter(Boolean))]; + + if (tenantIds.length === 1) { + console.warn( + `[inbound-tenant] attributed to the sole ${provider} tenant ${tenantIds[0]} — no tenant in the URL path and no app_identifier match. Fix the webhook endpoint or set app_identifier.`, + ); + return { tenantId: tenantIds[0] as string, via: "sole_provider_tenant" }; + } + if (tenantIds.length > 1) { + throw new Error( + `Ambiguous tenant: ${tenantIds.length} tenants have provider '${provider}'. The webhook URL must include /{tenant_id}, or the app must set app_identifier.`, + ); + } + + return { tenantId: null, via: "self_hosted" }; +} diff --git a/supabase/functions/_shared/rate-limit.ts b/supabase/functions/_shared/rate-limit.ts index 74b75ebc..a6fc4065 100644 --- a/supabase/functions/_shared/rate-limit.ts +++ b/supabase/functions/_shared/rate-limit.ts @@ -5,7 +5,7 @@ // pre-auth webhook handlers + response headers (X-RateLimit-*) so clients can // pace themselves without hitting 429s. -import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2" +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" export class RateLimitError extends Error { constructor(public bucket: string) { diff --git a/supabase/functions/_shared/subscription-handler.ts b/supabase/functions/_shared/subscription-handler.ts index 97bccf84..6408598e 100644 --- a/supabase/functions/_shared/subscription-handler.ts +++ b/supabase/functions/_shared/subscription-handler.ts @@ -1,5 +1,6 @@ -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; import { reconcileEntitlement, type CanonicalState } from "./entitlement-reconcile.ts"; +import { resolveInboundTenant } from "./inbound-tenant.ts"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, @@ -47,6 +48,11 @@ interface SubscriptionEvent { trialStart?: Date | null; /** Provider-emitted trial end. NULL when no trial. Client computes isInTrial from this. */ trialEnd?: Date | null; + /** + * The app the event belongs to (Play `packageName` / Apple `bundleId`), when the endpoint is + * per-deployment rather than per-tenant. Resolved against `tenants.app_identifier`. + */ + appIdentifier?: string | null; } export async function handleSubscriptionEvent(data: SubscriptionEvent) { @@ -59,16 +65,16 @@ export async function handleSubscriptionEvent(data: SubscriptionEvent) { // provider is configured for exactly ONE tenant, attribute to it. If >1, reject rather // than write a NULL-tenant row. Zero provider-tenants ⇒ genuine single-tenant self-hosted // deployment ⇒ NULL is correct and preserved. + // Delegated to the shared inbound resolver (path → app_identifier → sole provider tenant → + // self-hosted null). The rule used to live here and only here, which is why the STORE lanes — + // which never call this function — had no attribution at all. if (!data.tenantId) { - const { data: provRows } = await supabase - .from("tenant_providers").select("tenant_id").eq("provider", data.provider); - const tenantIds = [...new Set((provRows ?? []).map((r: any) => r.tenant_id).filter(Boolean))]; - if (tenantIds.length === 1) { - data.tenantId = tenantIds[0]; - console.warn(`[subscription-handler] tenantId absent from webhook URL — attributed to sole ${data.provider} tenant ${data.tenantId}. Fix the webhook endpoint to /${data.provider}-webhook/{tenant_id}.`); - } else if (tenantIds.length > 1) { - throw new Error(`Ambiguous tenant: ${tenantIds.length} tenants have provider '${data.provider}'. Webhook URL must include /{tenant_id}.`); - } + const resolved = await resolveInboundTenant({ + tenantIdFromPath: data.tenantId, + appIdentifier: data.appIdentifier ?? null, + provider: data.provider, + }); + data.tenantId = resolved.tenantId; } console.log(`Processing subscription event: provider=${data.provider}, sub=${data.subscriptionId}, status=${data.status}, tenant=${data.tenantId || "self-hosted"}`); diff --git a/supabase/functions/_shared/supabase-admin.ts b/supabase/functions/_shared/supabase-admin.ts index 2d149452..64c46a09 100644 --- a/supabase/functions/_shared/supabase-admin.ts +++ b/supabase/functions/_shared/supabase-admin.ts @@ -2,7 +2,7 @@ // Shared service-role Supabase client for the E2 reconciliation engine. // Edge functions always run with SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY injected; // the client is constructed once at module load and reused by every reconcile/receipt path. -import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; export const supabaseAdmin: SupabaseClient = createClient( Deno.env.get("SUPABASE_URL")!, diff --git a/supabase/functions/_shared/tier-gate.ts b/supabase/functions/_shared/tier-gate.ts index 241c6f7c..118ec20b 100644 --- a/supabase/functions/_shared/tier-gate.ts +++ b/supabase/functions/_shared/tier-gate.ts @@ -1,7 +1,7 @@ // supabase/functions/_shared/tier-gate.ts // Shared tier-gate helper used at every edge function entry. -import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2" +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" export class TierGateError extends Error { constructor(public gate: string) { diff --git a/supabase/functions/_shared/webhook-rate-limit.ts b/supabase/functions/_shared/webhook-rate-limit.ts index 3b7ae75a..d0b2d09e 100644 --- a/supabase/functions/_shared/webhook-rate-limit.ts +++ b/supabase/functions/_shared/webhook-rate-limit.ts @@ -15,7 +15,7 @@ // (one noisy Stripe burst doesn't burn the Razorpay budget). Defaults follow // the published SLA: 60 req/60s sustained, allow short 60-burst. -import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.39.3?target=deno" +import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.106.1?target=deno" import { RateLimitError, rateLimitHeaders, diff --git a/supabase/functions/ad-reward/index.ts b/supabase/functions/ad-reward/index.ts new file mode 100644 index 00000000..5506f076 --- /dev/null +++ b/supabase/functions/ad-reward/index.ts @@ -0,0 +1,199 @@ +/** + * ad-reward — AdMob server-side-verification callback. The ONLY thing that may credit `ad_reward`. + * + * ## Why a callback and not a client call + * + * A client that could say "I watched an ad" mints currency with no attention sold — the same shape + * as the caller-supplied subject id migration 159 closed, and the caller-supplied email 162 closed. + * So `entitlement_balance_ad_reward` is granted to `service_role` only and has no client-callable + * overload, and this function is its sole caller. + * + * AdMob signs each callback with ECDSA (secp256r1) over the query string and publishes the public + * keys. Verifying that signature is what separates "a user watched an ad" from "someone sent us an + * HTTP request". + * + * ## The signature covers a SUBSTRING, not the parsed params + * + * Google signs the raw query string up to (but excluding) `&signature=`. Re-serialising parsed + * params would reorder or re-encode them and the signature would never verify — so the raw string + * is sliced, never rebuilt. This is the single easiest thing to get wrong here. + * + * GET /ad-reward?...&custom_data=|&signature=...&key_id=... + * 200 OK on success AND on replay (AdMob retries; a retry is normal traffic, not a fault). + */ + +import { supabaseAdmin } from "../_shared/supabase-admin.ts"; + +const VERIFIER_KEYS_URL = "https://www.gstatic.com/admob/reward/verifier-keys.json"; + +interface VerifierKey { + keyId: number; + pem: string; + base64: string; +} + +/** + * Key set cached for the isolate's lifetime. Google rotates these rarely; refetching per callback + * would add a third-party round-trip to every ad reward and make our credit path depend on + * gstatic's latency. + */ +let keyCache: { fetchedAt: number; keys: Map } | null = null; +const KEY_TTL_MS = 6 * 60 * 60 * 1000; + +async function verifierKeys(): Promise> { + if (keyCache && Date.now() - keyCache.fetchedAt < KEY_TTL_MS) return keyCache.keys; + + const res = await fetch(VERIFIER_KEYS_URL); + if (!res.ok) throw new Error(`verifier keys unavailable (${res.status})`); + const body = (await res.json()) as { keys: VerifierKey[] }; + + const keys = new Map(); + for (const k of body.keys ?? []) { + // `base64` is the raw X9.62 uncompressed EC point Google publishes; import it as a raw P-256 + // public key for ECDSA verification. + const raw = Uint8Array.from(atob(k.base64), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + "raw", + raw, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + keys.set(Number(k.keyId), key); + } + keyCache = { fetchedAt: Date.now(), keys }; + return keys; +} + +/** + * DER (SEQUENCE of two INTEGERs) → raw r||s, which is what WebCrypto's ECDSA verify expects. + * + * Returns `Uint8Array` explicitly: a plain `Uint8Array` is `ArrayBufferLike`, which + * could be backed by a `SharedArrayBuffer`, and `crypto.subtle.verify` will not accept that. + */ +function derToRaw(der: Uint8Array): Uint8Array { + let i = 2; // skip SEQUENCE tag + length + if (der[1] & 0x80) i = 2 + (der[1] & 0x7f); + const readInt = (): Uint8Array => { + if (der[i] !== 0x02) throw new Error("malformed DER signature"); + const len = der[i + 1]; + let start = i + 2; + let end = start + len; + // Strip the leading zero DER adds to keep an integer positive. + while (der[start] === 0x00 && end - start > 32) start++; + i = end; + const out = new Uint8Array(new ArrayBuffer(32)); + out.set(der.subarray(start, end), 32 - (end - start)); + return out; + }; + const r = readInt(); + const s = readInt(); + const raw = new Uint8Array(new ArrayBuffer(64)); + raw.set(r, 0); + raw.set(s, 32); + return raw; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +Deno.serve(async (req) => { + const url = new URL(req.url); + const q = url.searchParams; + + const signatureB64 = q.get("signature"); + const keyId = q.get("key_id"); + const customData = q.get("custom_data") ?? ""; + const transactionId = q.get("transaction_id") ?? ""; + const rewardAmount = Number(q.get("reward_amount") ?? "0"); + + if (!signatureB64 || !keyId) return json({ error: "missing signature or key_id" }, 400); + if (!transactionId) return json({ error: "missing transaction_id" }, 400); + + // ── the signed substring ──────────────────────────────────────────────────────────────────── + const raw = url.search.startsWith("?") ? url.search.slice(1) : url.search; + const sigIndex = raw.indexOf("&signature="); + if (sigIndex < 0) return json({ error: "signature must not be the first parameter" }, 400); + const signedPayload = raw.slice(0, sigIndex); + + // ── verify ────────────────────────────────────────────────────────────────────────────────── + try { + const keys = await verifierKeys(); + const key = keys.get(Number(keyId)); + if (!key) return json({ error: `unknown key_id ${keyId}` }, 401); + + const sigDer = Uint8Array.from( + atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/")), + (c) => c.charCodeAt(0), + ); + const ok = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + derToRaw(sigDer), + new TextEncoder().encode(signedPayload), + ); + if (!ok) return json({ error: "invalid signature" }, 401); + } catch (e) { + return json({ error: `verification failed: ${(e as Error).message}` }, 401); + } + + // ── who and what ──────────────────────────────────────────────────────────────────────────── + // custom_data is "|", set by the SDK when it requested the ad. A device token + // rather than a subject id, so a caller can only ever name itself. + const [deviceToken, sku] = customData.split("|"); + if (!deviceToken || !sku) { + // The ad was watched but we cannot attribute it. Loud: the user spent attention for nothing, + // and the cause is a client that did not set customData. + return json({ error: "custom_data must be '|'" }, 400); + } + + const { data: device, error: deviceError } = await supabaseAdmin + .from("registered_devices") + .select("subject_id, tenant_id") + .eq("device_token", deviceToken) + .eq("is_active", true) + .maybeSingle(); + + if (deviceError) return json({ error: `device lookup failed: ${deviceError.message}` }, 500); + if (!device?.subject_id) return json({ error: "unknown or inactive device token" }, 404); + + // ── how much ──────────────────────────────────────────────────────────────────────────────── + // The CATALOGUE decides, not the callback. `reward_amount` is configured in the AdMob console and + // is outside our control; taking it verbatim would let an ad-unit misconfiguration (or anyone who + // can forge a unit) set the price of our currency. Fall back to it only when the tenant has not + // declared a per-ad reward. + const { data: product } = await supabaseAdmin + .from("tenant_products") + .select("ad_reward_quantity, grant_sku, sku") + .eq("tenant_id", device.tenant_id) + .eq("sku", sku) + .maybeSingle(); + + const quantity = Number(product?.ad_reward_quantity ?? 0) > 0 + ? Number(product!.ad_reward_quantity) + : 0; + + if (quantity <= 0) { + return json({ + error: "ad rewards not enabled for this sku", + detail: "set tenant_products.ad_reward_quantity", + }, 409); + } + + const { error: creditError } = await supabaseAdmin.rpc("entitlement_balance_ad_reward", { + p_tenant_id: device.tenant_id, + p_subject_id: device.subject_id, + // Credit the CURRENCY (migration 164), so an ad reward lands in the same balance a purchase + // and a focus session do rather than in a per-product pocket of its own. + p_sku: (product?.grant_sku ?? "").trim() || sku, + p_quantity: quantity, + // AdMob's transaction_id is stable per reward and is retried verbatim, so it is the natural + // idempotency anchor. Prefixed, like every other provider, so two networks cannot collide. + p_idempotency_key: `admob:${transactionId}`, + }); + + if (creditError) return json({ error: `credit failed: ${creditError.message}` }, 500); + + // 200 on replay too: AdMob retries until it gets one, and the RPC absorbed the duplicate. + return json({ ok: true, sku, quantity, declared_reward_amount: rewardAmount }); +}); diff --git a/supabase/functions/apple-server-notifications/index.ts b/supabase/functions/apple-server-notifications/index.ts index b815f2c3..0772c529 100644 --- a/supabase/functions/apple-server-notifications/index.ts +++ b/supabase/functions/apple-server-notifications/index.ts @@ -9,6 +9,8 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { verifyStoreKit2Jws } from "../_shared/receipt-validate.ts"; +import { handleConsumablePurchase } from "../_shared/consumable-handler.ts"; +import { resolveInboundTenant } from "../_shared/inbound-tenant.ts"; import { reconcileEntitlement, appleToCanonical } from "../_shared/entitlement-reconcile.ts"; import { appStoreServerJwt } from "../_shared/apple-jwt.ts"; @@ -32,6 +34,21 @@ async function getAllSubscriptionStatuses( return await res.json(); } +/** Decode the JWS transaction payload (middle segment, base64url). Null when absent/unparseable. */ +function decodeSignedTransaction(signed: string | undefined): Record | null { + const seg = (signed ?? "").split(".")[1]; + if (!seg) return null; + try { + return JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(seg.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)), + ), + ); + } catch { + return null; + } +} + serve(async (req: Request) => { try { const body = await req.json(); @@ -39,24 +56,66 @@ serve(async (req: Request) => { const notif = await verifyStoreKit2Jws(body.signedPayload); const data = notif.data ?? {}; const originalTxn: string | undefined = data.originalTransactionId ?? - // Fall back to the signed transaction info payload if the envelope omits it. - (() => { - const seg = (data.signedTransactionInfo ?? "").split(".")[1]; - if (!seg) return undefined; - const tx = JSON.parse( - new TextDecoder().decode( - Uint8Array.from(atob(seg.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)), - ), - ); - return tx.originalTransactionId; - })(); + // Fall back to the signed transaction info payload if the envelope omits it. Shares the + // decoder with the ONE_TIME_CHARGE path below rather than inlining a second copy — the + // original inline version also threw on a malformed segment, where the helper returns null. + decodeSignedTransaction(data.signedTransactionInfo)?.originalTransactionId; if (!originalTxn) { return new Response("missing originalTransactionId", { status: 400 }); } + // ── One-time charge (consumable pack) ───────────────────────────────────────────────────── + // ASSN-V2 fires ONE_TIME_CHARGE for a consumable / non-consumable purchase. It has no + // subscription to reconcile, so it previously flowed into getAllSubscriptionStatuses below and + // produced nothing useful — the customer was charged and the balance never moved. + // + // Like Play, this endpoint is per-DEPLOYMENT with no tenant, so the grant derives it from the + // catalogue row owning this productId as its app_store_product_id. + if (notif.notificationType === "ONE_TIME_CHARGE") { + const tx = decodeSignedTransaction(data.signedTransactionInfo); + const productId: string | undefined = tx?.productId; + // appAccountToken is the app's own user id, set by the SDK at purchase. Apple exposes no + // email, and originalTransactionId identifies the PURCHASE rather than the buyer — using it + // as identity would mint a fresh subject per purchase and scatter one player's balance. + const appUserId: string | undefined = tx?.appAccountToken; + if (!productId || !appUserId) { + console.error( + `[apple-server-notifications] ONE_TIME_CHARGE missing ${!productId ? "productId" : "appAccountToken"} — cannot attribute the grant`, + ); + return new Response("ok", { status: 200 }); + } + const grant = await handleConsumablePurchase({ + tenantId: null, + // bundleId identifies the APP; tenants.app_identifier holds the same string for both + // stores (migration 111). Falls back to the signed transaction's copy when the envelope + // omits it, the same way originalTransactionId is recovered above. + appIdentifier: data.bundleId ?? tx?.bundleId ?? null, + storeProductId: productId, + identity: { kind: "auth_user", value: appUserId }, + // transactionId is unique per purchase; originalTransactionId is shared across a renewal + // family, so keying on it would make a second pack look like a redelivery of the first. + stableTxnId: tx?.transactionId ?? originalTxn, + provider: "app_store", + eventType: notif.notificationType, + }); + if (!grant.granted) { + console.error(`[apple-server-notifications] consumable grant refused for ${productId}: ${grant.reason}`); + } + return new Response("ok", { status: 200 }); + } + const sandbox = (notif.data?.environment ?? notif.environment) === "Sandbox"; const truth = await getAllSubscriptionStatuses(originalTxn, await appStoreServerJwt(), sandbox); - await reconcileEntitlement(appleToCanonical(truth, originalTxn)); + // Attribute to the APP — see the Play handler: a NULL-tenant entitlement row silently reads as + // not-premium on a multi-tenant deployment. bundleId is the same string Play calls packageName. + const { tenantId: subTenantId } = await resolveInboundTenant({ + appIdentifier: data.bundleId ?? decodeSignedTransaction(data.signedTransactionInfo)?.bundleId ?? null, + provider: "app_store", + }); + await reconcileEntitlement({ + ...appleToCanonical(truth, originalTxn), + tenantId: subTenantId, + }); return new Response("ok", { status: 200 }); } catch (err) { console.error("apple-server-notifications error:", (err as Error).message); diff --git a/supabase/functions/billing/index.ts b/supabase/functions/billing/index.ts index dcf6211a..0af41df8 100644 --- a/supabase/functions/billing/index.ts +++ b/supabase/functions/billing/index.ts @@ -5,7 +5,7 @@ // POST /functions/v1/billing/downgrade {tenant_id, target_tier} → {ok: true} import { serve } from "https://deno.land/std@0.208.0/http/server.ts" -import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" import Stripe from "https://esm.sh/stripe@14.21.0?target=deno" serve(async (req: Request): Promise => { diff --git a/supabase/functions/cashfree-webhook/index.ts b/supabase/functions/cashfree-webhook/index.ts index adcd25f5..a7471061 100644 --- a/supabase/functions/cashfree-webhook/index.ts +++ b/supabase/functions/cashfree-webhook/index.ts @@ -1,6 +1,7 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { withWebhookRateLimit } from "../_shared/webhook-rate-limit.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3?target=deno"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1?target=deno"; +import { handleConsumablePurchase } from "../_shared/consumable-handler.ts"; import { handleSubscriptionEvent } from "../_shared/subscription-handler.ts"; /** @@ -52,7 +53,7 @@ async function loadTenantWebhookSecret( p_provider: "cashfree", p_mode: mode, }) - .maybeSingle(); + .maybeSingle<{ secret_key: string | null; webhook_secret: string | null; key_id: string | null; credential: string | null }>(); if (error) { console.error( `[cashfree-webhook] decrypt failed (tenant=${tenantId}, mode=${mode}): ${error.message}`, @@ -232,6 +233,32 @@ serve(withWebhookRateLimit({ bucket: "webhook:cashfree" }, async (req) => { ); } const product = products[0]; + + // Consumable / one-time packs credit a BALANCE rather than an entitlement row. Cashfree is + // a one-time-only lane (subscriptions self-skip in its product sync), so this is the + // product kind it is best suited to sell — and before this branch existed the purchase + // became a lifetime subscription row instead of the currency the buyer paid for. + if (product.type === "consumable" || product.type === "one_time") { + const grant = await handleConsumablePurchase({ + tenantId: product.tenant_id, + email, + productId: product.id, + // The order id is the stable per-purchase anchor; the link id is reused across every + // purchase of that product, so keying on it would make the SECOND buyer a redelivery + // of the first and silently grant them nothing. + stableTxnId: order?.order_id ?? null, + provider: "cashfree", + eventType, + }); + if (!grant.granted) { + console.error(`[cashfree-webhook] consumable grant refused: ${grant.reason}`); + } + return new Response( + JSON.stringify({ received: true, granted: grant.quantity, balance: grant.balance }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + await handleSubscriptionEvent({ email, provider: "cashfree", diff --git a/supabase/functions/checkout-initiate/index.ts b/supabase/functions/checkout-initiate/index.ts index b50228d1..5a8cfea6 100644 --- a/supabase/functions/checkout-initiate/index.ts +++ b/supabase/functions/checkout-initiate/index.ts @@ -30,7 +30,7 @@ // `paycraft_tenant_id`, `paycraft_product_id` — so `subscription.authenticated` / `.charged` flow // into the existing entitlement handler with no further work. -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; import { RateLimitError, rateLimitResponse, requireRateLimit } from "../_shared/rate-limit.ts"; const RAZORPAY_API = "https://api.razorpay.com/v1"; diff --git a/supabase/functions/cloud-billing-webhook/index.ts b/supabase/functions/cloud-billing-webhook/index.ts index 71fc428e..ae1e5a2b 100644 --- a/supabase/functions/cloud-billing-webhook/index.ts +++ b/supabase/functions/cloud-billing-webhook/index.ts @@ -1,7 +1,7 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { withWebhookRateLimit } from "../_shared/webhook-rate-limit.ts"; import Stripe from "https://esm.sh/stripe@14.0.0?target=deno"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; /** * Cloud Billing Webhook — handles PayCraft Cloud's own Stripe subscription events. diff --git a/supabase/functions/config/index.ts b/supabase/functions/config/index.ts index f035b57e..c6293286 100644 --- a/supabase/functions/config/index.ts +++ b/supabase/functions/config/index.ts @@ -10,7 +10,7 @@ // } import { serve } from "https://deno.land/std@0.208.0/http/server.ts" -import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" import { diverges, resolveServed, resolveShadow } from "../_shared/pricing-shadow.ts" import { RateLimitError, diff --git a/supabase/functions/coupon-validate/index.ts b/supabase/functions/coupon-validate/index.ts index 6218e03d..7d5448d2 100644 --- a/supabase/functions/coupon-validate/index.ts +++ b/supabase/functions/coupon-validate/index.ts @@ -18,7 +18,7 @@ // happens later via Stripe Checkout — this endpoint never mutates state. import { serve } from "https://deno.land/std@0.208.0/http/server.ts" -import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" import { RateLimitError, rateLimitResponse, @@ -57,7 +57,12 @@ serve(async (req: Request): Promise => { // 2. Per-tenant rate limit (60 attempts burst, 1 refill/sec) — protects // against code-guessing brute force. try { - await requireRateLimit(supabase, tenantId, "coupon_validate", 60, 1) + // ipAddress was MISSING, so every argument shifted one place: the bucket became "60", maxTokens + // became 1 and refillPerSec was undefined — the comment above promised a 60-burst/1-per-sec + // limit and the call delivered a one-token bucket that never refilled. It is also the fallback + // key for a caller with no tenant, which a brute-force guard cannot do without. + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown" + await requireRateLimit(supabase, tenantId, ipAddress, "coupon_validate", 60, 1) } catch (e) { if (e instanceof RateLimitError) return rateLimitResponse(e) throw e diff --git a/supabase/functions/google-rtdn/index.ts b/supabase/functions/google-rtdn/index.ts index 8a8322c0..b6448dbf 100644 --- a/supabase/functions/google-rtdn/index.ts +++ b/supabase/functions/google-rtdn/index.ts @@ -9,6 +9,8 @@ // configured in the Play Console. Service-account credentials for the re-fetch come from play-jwt.ts. import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; +import { handleConsumablePurchase } from "../_shared/consumable-handler.ts"; +import { resolveInboundTenant } from "../_shared/inbound-tenant.ts"; import { reconcileEntitlement, googleToCanonical } from "../_shared/entitlement-reconcile.ts"; import { assertPlayTokenNotReused } from "../_shared/receipt-validate.ts"; import { playDeveloperJwt } from "../_shared/play-jwt.ts"; @@ -29,6 +31,33 @@ async function subscriptionsv2Get( return await res.json(); } +/** + * Resolve the app-supplied account id for a one-time purchase via `purchases.products.get`. + * + * Separate from the subscription path's `subscriptionsv2Get`: managed products are a different + * Play resource and carry the identifier under `obfuscatedExternalAccountId` on the product + * purchase itself. Returns null when the app did not set one, which is a REFUSAL rather than a + * fallback to the purchase token: attributing a paid grant to a token would mint a throwaway + * subject the buyer can never sign back into. + */ +async function oneTimeProductAppUserId( + packageName: string, + sku: string, + token: string, + jwt: string, +): Promise { + const res = await fetch( + `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}/purchases/products/${encodeURIComponent(sku)}/tokens/${encodeURIComponent(token)}`, + { headers: { Authorization: `Bearer ${jwt}` } }, + ); + if (!res.ok) { + console.error(`[google-rtdn] purchases.products.get failed (${res.status})`); + return null; + } + const purchase = await res.json(); + return purchase?.obfuscatedExternalAccountId ?? null; +} + serve(async (req: Request) => { try { const envelope = await req.json(); // Pub/Sub push: { message: { data: } } @@ -43,21 +72,76 @@ serve(async (req: Request) => { ), ); // DeveloperNotification + // ── One-time product (consumable pack) ──────────────────────────────────────────────────── + // notificationType 1 = ONE_TIME_PRODUCT_PURCHASED, 2 = ONE_TIME_PRODUCT_CANCELED. Until now + // this notification fell into the ack-and-drop below, so a pack bought on Play charged the + // customer and credited nothing. + // + // There is no tenant on this endpoint — the Pub/Sub topic is per-DEPLOYMENT — so the grant + // resolves it from the catalogue row that owns this `sku` as its play_product_id, which the + // product sync wrote back when it created the product. + const oneTime = rtdn.oneTimeProductNotification; + if (oneTime?.purchaseToken && oneTime?.sku) { + if (oneTime.notificationType !== 1) { + // Only a PURCHASE grants. A cancellation/refund is a negative ledger row, which belongs + // with the refund path rather than being inferred here — granting on it would be wrong and + // silently reversing a spend the player has already made would be worse. + return new Response("ok", { status: 200 }); + } + const jwt = await playDeveloperJwt(); + // The obfuscated account id is the app's OWN user id, threaded through the purchase by the + // SDK. It is the only buyer identity Play exposes — there is no email on a store purchase. + const appUserId = await oneTimeProductAppUserId(rtdn.packageName, oneTime.sku, oneTime.purchaseToken, jwt); + if (!appUserId) { + console.error(`[google-rtdn] one-time purchase ${oneTime.sku} has no account id — cannot attribute the grant`); + return new Response("ok", { status: 200 }); + } + const grant = await handleConsumablePurchase({ + tenantId: null, + // The APP the purchase happened in — this is what attributes it to a tenant + // (tenants.app_identifier), exactly as a per-tenant-app scoping requires. Resolving on the + // sku alone would mis-attribute when two tenants ship the same product id. + appIdentifier: rtdn.packageName, + storeProductId: oneTime.sku, + identity: { kind: "auth_user", value: appUserId }, + // The purchase token is unique per purchase and is what Play redelivers on, so it is the + // correct idempotency anchor. + stableTxnId: oneTime.purchaseToken, + provider: "google_play", + eventType: `oneTimeProduct.${oneTime.notificationType}`, + }); + if (!grant.granted) { + console.error(`[google-rtdn] consumable grant refused for ${oneTime.sku}: ${grant.reason}`); + } + return new Response("ok", { status: 200 }); + } + const sub = rtdn.subscriptionNotification; if (!sub?.purchaseToken) { - // Not a subscription notification (test publish / voidedPurchase / oneTimeProduct) — ack. + // Not a subscription notification (test publish / voidedPurchase) — ack. return new Response("ok", { status: 200 }); } const jwt = await playDeveloperJwt(); const truth = await subscriptionsv2Get(rtdn.packageName, sub.purchaseToken, jwt); + // Attribute to the APP. Until now every Play subscription was reconciled with tenant_id NULL, + // and a NULL-tenant row never matches check_premium_with_device()'s + // `tenant_id IS NOT DISTINCT FROM` filter — so is_premium read false for a paying customer on + // any multi-tenant deployment. Same resolver the consumable path and the web PSPs use. + const { tenantId: subTenantId } = await resolveInboundTenant({ + appIdentifier: rtdn.packageName, + provider: "google_play", + }); // Prefer the developer-supplied obfuscated account id for stable identity linking. const appUserId: string = truth.externalAccountIdentifiers?.obfuscatedExternalAccountId ?? truth.externalAccountIdentifiers?.externalAccountId ?? sub.purchaseToken; await assertPlayTokenNotReused(sub.purchaseToken, appUserId); - await reconcileEntitlement(googleToCanonical(truth, sub.purchaseToken, appUserId)); + await reconcileEntitlement({ + ...googleToCanonical(truth, sub.purchaseToken, appUserId), + tenantId: subTenantId, + }); return new Response("ok", { status: 200 }); } catch (err) { console.error("google-rtdn error:", (err as Error).message); diff --git a/supabase/functions/identity-verify/index.ts b/supabase/functions/identity-verify/index.ts new file mode 100644 index 00000000..4aade867 --- /dev/null +++ b/supabase/functions/identity-verify/index.ts @@ -0,0 +1,138 @@ +/** + * identity-verify — turn a consumer's auth token into a VERIFIED PayCraft device registration. + * + * ## Why this exists + * + * `register_device` is reachable from `anon` and takes a client-supplied EMAIL. Since + * `entitlement_subject_resolve` matches on (kind, provider, value) regardless of verified-ness, an + * unverified claim returns the subject a real sign-in created — so anyone holding the publishable + * key (it ships in every app binary) could name a victim's address and inherit their premium and + * their balance. Migration 159 closed the same class on the balance RPCs by refusing a + * caller-supplied subject ID; this closes the registration door by requiring PROOF instead of a + * string. + * + * ## Why verification lives here and not in plpgsql + * + * JWT signature checking in SQL would mean fetching remote JWKS from inside the database and + * hand-rolling crypto in plpgsql. Keeping it in one TypeScript file makes the trust boundary + * auditable: everything below is "what did the token prove", and the only thing handed to Postgres + * is already-verified claims via `register_verified_device` (service_role). + * + * ## Asymmetric only, deliberately + * + * Verification uses the tenant's PUBLIC JWKS (`tenants.auth_issuer_url`). PayCraft therefore never + * stores a consumer's signing secret — a symmetric (HS256) scheme would require exactly that, and + * one leaked tenant secret would let an attacker mint identities for that app at will. A tenant + * whose issuer cannot serve a JWKS simply does not get verified identity; its devices stay + * unverified, which is a working state (they keep anonymous/device-only access). + * + * POST { api_key, access_token, device_token, platform, device_name? } + * 200 { subject_id, verified: true } + * 4xx { error } + */ + +import { createRemoteJWKSet, jwtVerify } from "https://deno.land/x/jose@v5.9.6/index.ts"; +import { supabaseAdmin } from "../_shared/supabase-admin.ts"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +/** + * One JWKS client per issuer, cached for the isolate's lifetime. + * + * `createRemoteJWKSet` does its own key caching and rotation; building a new one per request would + * refetch the key set on every call and turn a signature check into a network round-trip against + * the consumer's auth server — a self-inflicted rate limit on their project, not ours. + */ +const jwksCache = new Map>(); + +function jwksFor(issuerUrl: string) { + const base = issuerUrl.replace(/\/+$/, ""); + let set = jwksCache.get(base); + if (!set) { + set = createRemoteJWKSet(new URL(`${base}/auth/v1/.well-known/jwks.json`)); + jwksCache.set(base, set); + } + return set; +} + +Deno.serve(async (req) => { + if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405); + + let body: Record; + try { + body = await req.json(); + } catch { + return json({ error: "invalid_json" }, 400); + } + + const apiKey = String(body.api_key ?? "").trim(); + const accessToken = String(body.access_token ?? "").trim(); + const deviceToken = String(body.device_token ?? "").trim(); + const platform = String(body.platform ?? "").trim(); + const deviceName = body.device_name ? String(body.device_name) : null; + + if (!apiKey || !accessToken || !deviceToken || !platform) { + return json({ error: "api_key, access_token, device_token and platform are required" }, 400); + } + + // ── which app is this, and can its tokens be verified at all ────────────────────────────────── + const { data: tenant, error: tenantError } = await supabaseAdmin + .from("tenants") + .select("id, auth_issuer_url") + .or(`api_key_test.eq.${apiKey},api_key_live.eq.${apiKey}`) + .eq("status", "active") + .maybeSingle(); + + if (tenantError) return json({ error: `tenant_lookup_failed: ${tenantError.message}` }, 500); + if (!tenant) return json({ error: "unknown_or_inactive_api_key" }, 401); + + const issuer = (tenant.auth_issuer_url as string | null)?.trim(); + if (!issuer) { + // NOT an error the client can fix, and NOT something to work around by trusting the token. + // The tenant has not enabled verified identity; say so plainly so the operator knows the knob. + return json({ + error: "verified_identity_not_configured", + detail: "set tenants.auth_issuer_url to the app's auth issuer (e.g. https://.supabase.co)", + }, 409); + } + + // ── what did the token actually prove ───────────────────────────────────────────────────────── + let claims: Record; + try { + const { payload } = await jwtVerify(accessToken, jwksFor(issuer), { + // Supabase signs access tokens with `iss = /auth/v1`. Pinning it stops a token minted + // by a DIFFERENT project (whose JWKS this tenant does not control) from being accepted. + issuer: `${issuer.replace(/\/+$/, "")}/auth/v1`, + }); + claims = payload as Record; + } catch (e) { + // Signature, expiry and issuer failures collapse to one answer on purpose: distinguishing them + // tells an attacker which part of a forged token to fix next. + return json({ error: "invalid_identity_token", detail: (e as Error).message }, 401); + } + + const authSub = String(claims.sub ?? "").trim(); + if (!authSub) return json({ error: "token_has_no_subject" }, 401); + + // `email_verified` is advisory here — the SIGNATURE is what makes this trustworthy, and the email + // is only ever an alias on an already-verified auth_user subject. + const email = typeof claims.email === "string" && claims.email.trim() ? claims.email.trim() : null; + + // ── record it ───────────────────────────────────────────────────────────────────────────────── + const { data: subjectId, error: regError } = await supabaseAdmin.rpc("register_verified_device", { + p_tenant_id: tenant.id, + p_auth_sub: authSub, + p_email: email, + p_device_token: deviceToken, + p_platform: platform, + p_device_name: deviceName, + }); + + if (regError) return json({ error: `registration_failed: ${regError.message}` }, 500); + + return json({ subject_id: subjectId, verified: true }); +}); diff --git a/supabase/functions/paddle-webhook/index.ts b/supabase/functions/paddle-webhook/index.ts index b441a277..20fe94e9 100644 --- a/supabase/functions/paddle-webhook/index.ts +++ b/supabase/functions/paddle-webhook/index.ts @@ -1,7 +1,6 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { withWebhookRateLimit } from "../_shared/webhook-rate-limit.ts"; import { handleSubscriptionEvent } from "../_shared/subscription-handler.ts"; -import { createHmac } from "https://deno.land/std@0.177.0/crypto/mod.ts"; /** * Paddle Webhook Handler. diff --git a/supabase/functions/razorpay-webhook/index.ts b/supabase/functions/razorpay-webhook/index.ts index a04b3a2d..7216d422 100644 --- a/supabase/functions/razorpay-webhook/index.ts +++ b/supabase/functions/razorpay-webhook/index.ts @@ -1,6 +1,7 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { withWebhookRateLimit } from "../_shared/webhook-rate-limit.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3?target=deno"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1?target=deno"; +import { handleConsumablePurchase } from "../_shared/consumable-handler.ts"; import { handleSubscriptionEvent } from "../_shared/subscription-handler.ts"; /** @@ -98,7 +99,7 @@ async function loadTenantWebhookSecret( p_provider: "razorpay", p_mode: mode, }) - .maybeSingle(); + .maybeSingle<{ secret_key: string | null; webhook_secret: string | null; key_id: string | null; credential: string | null }>(); if (error) { console.error( `[razorpay-webhook] decrypt failed (tenant=${tenantId}, mode=${mode}): ${error.message}`, @@ -217,6 +218,49 @@ serve(withWebhookRateLimit({ bucket: "webhook:razorpay" }, async (req) => { ); if (!sub) { + // A captured PAYMENT with no subscription envelope is how a one-time / consumable purchase + // arrives — Razorpay Payment Links are the lane razorpay-product-sync creates for exactly + // those products. Until 2026-10-20 this branch acked and ignored every such event ("PayCraft + // only cares about subscription state"), so a coin pack bought through Razorpay charged the + // customer and credited nothing. + // + // The link is created with notes.paycraft_product_id (a UUID, not the sku) — see + // razorpay-product-sync.ts — so the product id is what gets threaded through. + const payment = parsed.payload?.payment?.entity as + | { id?: string; email?: string; notes?: Record } + | undefined; + const notes = payment?.notes ?? {}; + if (eventType === "payment.captured" && payment) { + const grant = await handleConsumablePurchase({ + tenantId, + email: payment.email || notes.paycraft_email || null, + productId: notes.paycraft_product_id || null, + sku: notes.paycraft_plan || null, + stableTxnId: payment.id ?? null, + provider: "razorpay", + eventType, + }); + if (grant.granted) { + console.log( + `[razorpay-webhook] granted ${grant.quantity} to ${payment.email ?? "unknown"} (balance ${grant.balance})`, + ); + return new Response( + JSON.stringify({ received: true, granted: grant.quantity, balance: grant.balance }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + // Not one of our consumables → the old ack-and-ignore. Anything else is logged: the customer + // has already been charged, so a refusal must leave a trace an operator can act on. + if ( + grant.reason && + !grant.reason.includes("not in this tenant's catalogue") && + !grant.reason.includes("not a consumable") && + !grant.reason.includes("no sku or product id") + ) { + console.error(`[razorpay-webhook] consumable grant refused: ${grant.reason}`); + } + } + // payment.* events without a subscription envelope — ack and ignore, // PayCraft only cares about subscription state. return new Response( diff --git a/supabase/functions/send-welcome/index.ts b/supabase/functions/send-welcome/index.ts index fa85a65c..33c59612 100644 --- a/supabase/functions/send-welcome/index.ts +++ b/supabase/functions/send-welcome/index.ts @@ -1,5 +1,5 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; import { sendEmail, welcomeEmail } from "../_shared/email.ts"; /** diff --git a/supabase/functions/stripe-connect-oauth/index.ts b/supabase/functions/stripe-connect-oauth/index.ts index 350ccf1b..0860c3c5 100644 --- a/supabase/functions/stripe-connect-oauth/index.ts +++ b/supabase/functions/stripe-connect-oauth/index.ts @@ -19,7 +19,7 @@ // payload shape, same full-length comparison, same max age. import { serve } from "https://deno.land/std@0.208.0/http/server.ts" -import { createClient } from "https://esm.sh/@supabase/supabase-js@2" +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1" import Stripe from "https://esm.sh/stripe@14.21.0?target=deno" /** diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index fdca410e..25c40d6c 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -1,7 +1,8 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { withWebhookRateLimit } from "../_shared/webhook-rate-limit.ts"; import Stripe from "https://esm.sh/stripe@14.0.0?target=deno"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.3?target=deno"; +import { createClient, type SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.106.1?target=deno"; +import { handleConsumablePurchase } from "../_shared/consumable-handler.ts"; import { handleSubscriptionEvent } from "../_shared/subscription-handler.ts"; /** @@ -63,7 +64,7 @@ async function loadTenantCreds( p_provider: "stripe", p_mode: mode, }) - .maybeSingle(); + .maybeSingle<{ secret_key: string | null; webhook_secret: string | null; key_id: string | null; credential: string | null }>(); if (error) { console.error( `[stripe-webhook] tenant_providers_decrypt_for_webhook failed (tenant=${tenantId}, mode=${mode}): ${error.message}`, @@ -243,6 +244,35 @@ serve(withWebhookRateLimit({ bucket: "webhook:stripe" }, async (req) => { break; } + // CONSUMABLE / one-time packs are credited to a BALANCE, not granted as access. Without + // this branch a coin pack bought on Stripe synthesized the lifetime-entitlement row + // below — handing the buyer permanent premium instead of the currency they paid for, + // and still leaving their balance at zero. Routed by the CATALOGUE's type, so the + // decision cannot drift from what the product actually is. + const consumableSku = session.metadata?.plan_id || null; + const consumable = await handleConsumablePurchase({ + tenantId, + email, + sku: consumableSku, + stableTxnId: (session.payment_intent as string) || `cs-${session.id}`, + provider: "stripe", + eventType: event.type, + }); + if (consumable.granted) { + console.log( + `[stripe-webhook] granted ${consumable.quantity} of ${consumableSku} to ${email} (balance ${consumable.balance})`, + ); + break; + } + // Not a consumable → fall through to the lifetime path. Anything else is logged with the + // reason: the buyer HAS been charged, so a refusal must never be silent. + if (consumable.reason && !consumable.reason.includes("not in this tenant's catalogue") && + !consumable.reason.includes("not a consumable")) { + console.error( + `[stripe-webhook] consumable grant refused for ${consumableSku}: ${consumable.reason}`, + ); + } + // ~100 years out — mirrors the SQL `NOW() + INTERVAL '100 years'`. const lifetimePeriodEnd = new Date( Date.now() + 100 * 365 * 24 * 60 * 60 * 1000, @@ -431,7 +461,10 @@ serve(withWebhookRateLimit({ bucket: "webhook:stripe" }, async (req) => { * increments are acceptable for this counter. */ async function maybeIncrementCouponRedemption( - supabase: ReturnType, + // SupabaseClient, not ReturnType: the bare return type carries an EMPTY + // schema, which types every rpc() argument object as `undefined` and makes a correct call a + // compile error. The explicit client type is what the rest of the edge functions use. + supabase: SupabaseClient, sub: Stripe.Subscription, ): Promise { const legacyCouponId = (sub as any).discount?.coupon?.id as string | undefined; diff --git a/supabase/functions/tenant-alerts/index.ts b/supabase/functions/tenant-alerts/index.ts index 7c6e7b20..f3d3ccd1 100644 --- a/supabase/functions/tenant-alerts/index.ts +++ b/supabase/functions/tenant-alerts/index.ts @@ -1,5 +1,5 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; import { sendEmail, limitWarningEmail, diff --git a/supabase/functions/webhook-health/index.ts b/supabase/functions/webhook-health/index.ts index 91cab547..8ef896bc 100644 --- a/supabase/functions/webhook-health/index.ts +++ b/supabase/functions/webhook-health/index.ts @@ -1,5 +1,5 @@ import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.106.1"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, diff --git a/supabase/migrations/159_balance_rpcs_resolve_subject_from_token.sql b/supabase/migrations/159_balance_rpcs_resolve_subject_from_token.sql new file mode 100644 index 00000000..20de2ca7 --- /dev/null +++ b/supabase/migrations/159_balance_rpcs_resolve_subject_from_token.sql @@ -0,0 +1,152 @@ +-- 159_balance_rpcs_resolve_subject_from_token.sql +-- +-- SECURITY FIX to 157/70124cf, and the reason the balance RPCs have no Kotlin caller. +-- +-- ## The hole +-- +-- 70124cf correctly removed `p_tenant_id` from the client-facing balance functions, reasoning that +-- "a p_tenant_id parameter would let any caller read ANOTHER tenant's balances by passing their +-- id". It then left `p_subject_id UUID` as a caller-supplied parameter on both, and granted them +-- to `anon, authenticated`: +-- +-- entitlement_balance_get (p_api_key, p_subject_id, p_sku) -> anon, authenticated +-- entitlement_balance_spend(p_api_key, p_subject_id, p_sku, qty, idem) -> anon, authenticated +-- +-- Both are SECURITY DEFINER, so they bypass RLS. The tenant is pinned by the key, but the SUBJECT +-- is accepted verbatim and never checked against the caller. The publishable key ships inside every +-- client binary, so within one tenant ANY caller holding it can read — and SPEND — another user's +-- balance given their subject UUID. That is the exact defect 70124cf fixed, one level down, and +-- `spend` makes it a drain rather than a leak. +-- +-- ## Why no Kotlin ever called these +-- +-- `entitlement_subject_resolve` is granted to `service_role` only, so a legitimate client cannot +-- obtain its OWN subject id. The only way to populate `p_subject_id` from a device was to already +-- know a UUID the client is never given. The missing caller and the vulnerability are one defect: +-- the parameter should never have been the client's to supply. +-- +-- ## The fix +-- +-- Client-facing overloads that take the DEVICE TOKEN and resolve the subject server-side, exactly +-- as `is_premium` / `get_subscription` / `check_premium_with_device` have since migration 155: +-- resolve_tenant(p_api_key), then look the subject up from `registered_devices.device_token` +-- scoped to that tenant. A caller can then only ever reach the subject its own token maps to. +-- +-- The subject-taking signatures are KEPT (the webhook grant path and the dashboard hold a real +-- subject id and no device token) but REVOKED from anon/authenticated, so they become +-- service_role-only like `entitlement_balance_of_tenant` already is. +-- +-- Overload resolution is by named parameters through PostgREST, and the two name SETS are +-- disjoint ({p_api_key,p_subject_id,p_sku} vs {p_server_token,p_api_key,p_sku}), so both can +-- coexist without ambiguity. +-- +-- Idempotent: CREATE OR REPLACE / GRANT / REVOKE are all safe to re-run (CLAUDE.md migration rules). + +-- ── 1. internal: device token -> subject, tenant-scoped ────────────────────────────────────────── +-- Mirrors the lookup inside is_premium (155). STABLE + SECURITY DEFINER because `registered_devices` +-- is RLS-protected and the client role cannot read it directly. service_role only: this is a +-- building block for the functions below, never a client entry point. +CREATE OR REPLACE FUNCTION public.entitlement_subject_of_token( + p_server_token TEXT, + p_tenant UUID +) +RETURNS UUID +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT subject_id + FROM registered_devices + WHERE device_token = p_server_token + AND is_active = true + AND (tenant_id IS NOT DISTINCT FROM p_tenant) + LIMIT 1; +$$; + +COMMENT ON FUNCTION public.entitlement_subject_of_token(TEXT, UUID) IS + 'Resolve a registered device token to its entitlement subject, scoped to the tenant. Internal ' + '(service_role only) — the client-facing balance RPCs call it so the subject is never a parameter.'; + +-- ── 2. client-facing balance read, subject derived from the token ──────────────────────────────── +CREATE OR REPLACE FUNCTION public.entitlement_balance_get( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT +) +RETURNS INT +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; +BEGIN + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + v_subject := entitlement_subject_of_token(p_server_token, v_tenant); + -- An unknown/inactive token is 0, not an error: a signed-out or not-yet-registered client asking + -- for its balance is an ordinary state, and the SDK already maps a failed read to 0 rather than a + -- cached optimistic value. Raising here would make a normal cold start look like a fault. + IF v_subject IS NULL THEN + RETURN 0; + END IF; + + RETURN entitlement_balance_of_tenant(v_tenant, v_subject, p_sku); +END; +$$; + +-- ── 3. client-facing spend, subject derived from the token ─────────────────────────────────────── +-- Delegates to the subject-taking implementation rather than restating it: the replay check, the +-- FOR UPDATE lock and the insufficient_balance contract are subtle and must not exist twice. +CREATE OR REPLACE FUNCTION public.entitlement_balance_spend( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; +BEGIN + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + v_subject := entitlement_subject_of_token(p_server_token, v_tenant); + -- A spend, unlike a read, MUST fail loudly on an unresolvable token. Returning 0 would be + -- indistinguishable from "spent, balance now 0" to a caller that is about to hand over goods. + IF v_subject IS NULL THEN + RAISE EXCEPTION 'unknown or inactive device token' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + RETURN public.entitlement_balance_spend(p_api_key, v_subject, p_sku, p_quantity, p_idempotency_key); +END; +$$; + +-- ── 4. grants ──────────────────────────────────────────────────────────────────────────────────── +-- The token-taking overloads become the client surface. +GRANT EXECUTE ON FUNCTION public.entitlement_balance_get(TEXT, TEXT, TEXT) TO anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_balance_spend(TEXT, TEXT, TEXT, INT, TEXT) TO anon, authenticated; + +-- The subject-taking overloads stop being reachable from a client. They remain for the webhook / +-- dashboard paths, which hold a real subject id and no device token. +REVOKE EXECUTE ON FUNCTION public.entitlement_balance_get(TEXT, UUID, TEXT) FROM anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entitlement_balance_spend(TEXT, UUID, TEXT, INT, TEXT) FROM anon, authenticated; + +REVOKE EXECUTE ON FUNCTION public.entitlement_subject_of_token(TEXT, UUID) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_subject_of_token(TEXT, UUID) TO service_role; + +COMMENT ON FUNCTION public.entitlement_balance_get(TEXT, TEXT, TEXT) IS + 'CLIENT-FACING balance read. Tenant from the publishable key, subject from the device token — ' + 'neither is a caller-supplied id. Returns 0 for an unresolvable token.'; +COMMENT ON FUNCTION public.entitlement_balance_spend(TEXT, TEXT, TEXT, INT, TEXT) IS + 'CLIENT-FACING checked spend. Tenant from the key, subject from the device token. Delegates to ' + 'the subject-taking implementation for the replay check, row lock and insufficient_balance error.'; diff --git a/supabase/migrations/160_entitlement_owns_sku.sql b/supabase/migrations/160_entitlement_owns_sku.sql new file mode 100644 index 00000000..812d596f --- /dev/null +++ b/supabase/migrations/160_entitlement_owns_sku.sql @@ -0,0 +1,91 @@ +-- 160_entitlement_owns_sku.sql — per-sku ownership for one-time (non-consumable) products. +-- +-- ## What this answers +-- +-- `is_premium` answers "is this user entitled to ANYTHING premium". A store needs the narrower +-- question "does this user already own THIS sku", so a bought lifetime unlock stops being offered +-- for sale and the content it unlocks opens. Without it `PayCraftProductGate` cannot exist, and a +-- store would happily sell a user something they already paid for. +-- +-- ## Identity, same as 159 +-- +-- Tenant from the publishable key, subject from the device token — neither is a caller-supplied id. +-- See 159 for why: a caller-supplied subject on a SECURITY DEFINER function granted to `anon` lets +-- one holder of the (client-shipped) publishable key read another user's entitlements. +-- +-- The predicate mirrors `is_premium` (155) exactly — subject match, falling back to email so a +-- backfill gap never revokes access — and adds two things: +-- +-- * `s.plan = p_sku`, which is what makes it per-product; and +-- * `current_period_end IS NULL` as current, because a lifetime entitlement carries a NULL +-- expiry (090). `is_premium` requires `> now()` and so would read every lifetime unlock as +-- EXPIRED. Checking NULL first also short-circuits before the NULL comparison that would +-- otherwise evaluate to NULL and read as false — the ordering 090 calls out explicitly. +-- +-- ## Known limit, stated rather than hidden +-- +-- `subscriptions` carries UNIQUE(email) (migration 001), so it holds at most ONE row per user and +-- therefore cannot represent a user who owns two different one-time products. `entitlement_records` +-- (072) can — it keys on (provider, stable_txn_id) and carries `product_id` — and 090 already built +-- `is_premium_by_app_user` on it. But 155 records it as `0 rows in production (2026-10-16)`: nothing +-- populates it yet, and its `app_user_id TEXT` has no defined relationship to an entitlement subject. +-- +-- Reading it here would mean inventing that relationship (is app_user_id the subject uuid? the +-- email?) and shipping a guess as a security predicate. So this reads the table that is actually +-- live. When entitlement_records starts being written AND its identity column is pinned to a +-- subject, this function should move to it and the single-product limit disappears with the move. +-- Until then: one one-time product per user, which is what the storage can honestly express. +-- +-- Idempotent: CREATE OR REPLACE / GRANT / REVOKE are safe to re-run (CLAUDE.md migration rules). + +CREATE OR REPLACE FUNCTION public.entitlement_owns( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT +) +RETURNS BOOLEAN +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; + v_email TEXT; + v_mode TEXT; +BEGIN + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + SELECT email, mode, subject_id INTO v_email, v_mode, v_subject + FROM registered_devices + WHERE device_token = p_server_token + AND is_active = true + AND (tenant_id IS NOT DISTINCT FROM v_tenant); + + -- Not registered yet is "owns nothing", not an error: a signed-out browser of the store is an + -- ordinary state. FALSE is also the safe default — it offers the product for sale rather than + -- unlocking content nobody paid for. + IF v_email IS NULL THEN + RETURN false; + END IF; + + RETURN EXISTS ( + SELECT 1 FROM subscriptions s + WHERE ((v_subject IS NOT NULL AND s.subject_id = v_subject) OR s.email = lower(v_email)) + AND s.plan = p_sku + AND s.status IN ('active', 'trialing', 'active_non_renewing') + AND (s.current_period_end IS NULL OR s.current_period_end > now()) + AND s.mode = v_mode + AND (s.tenant_id IS NOT DISTINCT FROM v_tenant) + ); +END; +$$; + +COMMENT ON FUNCTION public.entitlement_owns(TEXT, TEXT, TEXT) IS + 'CLIENT-FACING per-sku ownership for one-time products. Tenant from the publishable key, subject ' + 'from the device token (never caller-supplied). Treats a NULL current_period_end as current, ' + 'because a lifetime entitlement carries no expiry (090).'; + +GRANT EXECUTE ON FUNCTION public.entitlement_owns(TEXT, TEXT, TEXT) TO anon, authenticated; diff --git a/supabase/migrations/161_balance_earn_sources.sql b/supabase/migrations/161_balance_earn_sources.sql new file mode 100644 index 00000000..b0f75a02 --- /dev/null +++ b/supabase/migrations/161_balance_earn_sources.sql @@ -0,0 +1,215 @@ +-- 161 — one balance, several earn sources: purchase · ad_reward · activity. +-- +-- WHY +-- `entitlement_balance_ledger` already was the single source of truth for a consumable balance, but +-- only ONE thing could credit it: a store/PSP purchase webhook (160/157). That made every other way +-- a user can legitimately acquire currency unrepresentable, so the first consumer kept its OWN +-- ledger and the SDK drained one into the other. Two ledgers for one currency is the defect: the +-- user sees the host's number, the server holds a different one, and an ad reward has no home in +-- either without picking a side. +-- +-- This migration makes the server's ledger able to hold every source, so the host keeps none: +-- +-- purchase store / PSP webhook (157, 160) — money +-- ad_reward ad-network server callback (THIS) — attention, server-VERIFIED +-- activity the app's own earn events (THIS) — in-app effort (focus minutes, streaks) +-- spend client spend (159) +-- refund / adjustment (157) — support + ops +-- +-- ── THE DANGEROUS ONE ──────────────────────────────────────────────────────────────────────────── +-- `activity` is CLIENT-ASSERTED by construction. No server can observe that a user focused for 25 +-- minutes; only the app can say so. A client-callable credit is therefore a MINT, and a modified +-- client can claim any number it likes. That is not a reason to refuse the source — it is the +-- reason the cap lives HERE rather than in the app: +-- +-- * per-(tenant, sku) daily ceiling, declared by the tenant in `tenant_products.daily_earn_cap`; +-- * the ceiling is enforced inside the SECURITY DEFINER function, where the client cannot reach; +-- * over-cap is CLAMPED, not rejected — an honest user at their ceiling must not see an error, +-- and a dishonest one gains nothing by retrying; +-- * every credit still carries the caller's idempotency key, so a replayed earn is a no-op. +-- +-- `ad_reward` is deliberately NOT client-callable. An ad network's server-side-verification callback +-- credits it via service_role (the SSV edge function). A client that could assert "I watched an ad" +-- is minting currency with no attention actually sold — the same shape as the subject-id hole 159 +-- closed. The two ad surfaces are also NOT the same thing and must not be merged: `PayCraftAdUnlock` +-- grants a TIME-BOXED FEATURE unlock and is deliberately local-only, because a free feature has no +-- transferable value. Currency does. +-- +-- Idempotent: every statement is CREATE OR REPLACE / IF NOT EXISTS / guarded DO block. + +-- ── 1. the ledger must accept the new reasons ─────────────────────────────────────────────────── +-- Drop + re-add rather than ALTER: a CHECK constraint cannot be widened in place. +ALTER TABLE public.entitlement_balance_ledger + DROP CONSTRAINT IF EXISTS entitlement_balance_ledger_reason_check; + +ALTER TABLE public.entitlement_balance_ledger + ADD CONSTRAINT entitlement_balance_ledger_reason_check + CHECK (reason IN ('purchase','spend','refund','adjustment','ad_reward','activity')); + +COMMENT ON COLUMN public.entitlement_balance_ledger.reason IS + 'How the delta arose. purchase=store/PSP webhook · ad_reward=ad-network SSV callback ' + '(service_role only) · activity=the app''s own earn event (client-asserted, capped) · ' + 'spend=client spend · refund/adjustment=support + ops.'; + +-- ── 2. the tenant declares what an app may mint per day ───────────────────────────────────────── +-- NULL = this sku cannot be earned by activity at all (the safe default for every existing row). +ALTER TABLE public.tenant_products + ADD COLUMN IF NOT EXISTS daily_earn_cap INT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'tenant_products_daily_earn_cap_positive' + ) THEN + ALTER TABLE public.tenant_products + ADD CONSTRAINT tenant_products_daily_earn_cap_positive + CHECK (daily_earn_cap IS NULL OR daily_earn_cap >= 1); + END IF; +END $$; + +COMMENT ON COLUMN public.tenant_products.daily_earn_cap IS + 'Max units this sku may be credited by reason=activity per subject per UTC day. NULL means ' + 'activity earning is DISABLED for this sku — the safe default, because enabling it lets a ' + 'modified client mint up to this number.'; + +-- ── 3. how much has this subject already earned by activity today ─────────────────────────────── +CREATE OR REPLACE FUNCTION public.entitlement_activity_earned_today( + p_tenant_id UUID, + p_subject_id UUID, + p_sku TEXT +) +RETURNS INT +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE(SUM(delta), 0)::INT + FROM entitlement_balance_ledger + WHERE tenant_id = p_tenant_id + AND subject_id = p_subject_id + AND sku = p_sku + AND reason = 'activity' + AND occurred_at >= date_trunc('day', now() AT TIME ZONE 'utc'); +$$; + +-- ── 4. CLIENT-FACING activity credit ──────────────────────────────────────────────────────────── +-- Same auth shape as 159: tenant from the publishable key, subject from the device token. Neither +-- is a caller-supplied id, so a client can only ever credit ITSELF. +CREATE OR REPLACE FUNCTION public.entitlement_balance_earn( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; + v_cap INT; + v_earned INT; + v_grant INT; +BEGIN + IF p_quantity IS NULL OR p_quantity < 1 THEN + RAISE EXCEPTION 'quantity must be >= 1' USING ERRCODE = 'check_violation'; + END IF; + IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN + RAISE EXCEPTION 'idempotency_key is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + v_subject := entitlement_subject_of_token(p_server_token, v_tenant); + -- Loud, like spend: a credit silently going nowhere would show the user units they do not have. + IF v_subject IS NULL THEN + RAISE EXCEPTION 'unknown or inactive device token' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + SELECT daily_earn_cap INTO v_cap + FROM tenant_products + WHERE tenant_id = v_tenant AND sku = p_sku AND active; + + -- NULL cap = activity earning not enabled for this sku. Refuse rather than mint: a tenant that + -- never opted in must not be creditable just because a client asked. + IF v_cap IS NULL THEN + RAISE EXCEPTION 'sku % does not allow activity earning (set tenant_products.daily_earn_cap)', p_sku + USING ERRCODE = 'check_violation'; + END IF; + + v_earned := entitlement_activity_earned_today(v_tenant, v_subject, p_sku); + + -- CLAMP, never reject. An honest user who hits the ceiling mid-session should see their balance + -- stop rising, not an error dialog; a dishonest one gains nothing by retrying. + v_grant := LEAST(p_quantity, GREATEST(v_cap - v_earned, 0)); + + IF v_grant > 0 THEN + INSERT INTO entitlement_balance_ledger + (tenant_id, subject_id, sku, delta, reason, stable_txn_id, idempotency_key) + VALUES + (v_tenant, v_subject, p_sku, v_grant, 'activity', NULL, btrim(p_idempotency_key)) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; + END IF; + + RETURN public.entitlement_balance_get(p_api_key, v_subject, p_sku); +END; +$$; + +-- ── 5. SERVICE-ROLE ad reward ─────────────────────────────────────────────────────────────────── +-- Takes a subject id, NOT a device token, and is never granted to a client: the caller is the SSV +-- edge function, which has already verified the ad network's signature. There is deliberately no +-- client-callable overload — see the header. +CREATE OR REPLACE FUNCTION public.entitlement_balance_ad_reward( + p_tenant_id UUID, + p_subject_id UUID, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF p_quantity IS NULL OR p_quantity < 1 THEN + RAISE EXCEPTION 'quantity must be >= 1' USING ERRCODE = 'check_violation'; + END IF; + IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN + RAISE EXCEPTION 'idempotency_key is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + INSERT INTO entitlement_balance_ledger + (tenant_id, subject_id, sku, delta, reason, stable_txn_id, idempotency_key) + VALUES + (p_tenant_id, p_subject_id, p_sku, p_quantity, 'ad_reward', NULL, btrim(p_idempotency_key)) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; + + -- Same helper entitlement_balance_grant returns through (157) — tenant+subject shaped, which is + -- what this function holds. No EXCEPTION fallback: swallowing undefined_function here would turn + -- a missing dependency into a NULL balance the caller cannot distinguish from "zero units". + RETURN public.entitlement_balance_of_tenant(p_tenant_id, p_subject_id, p_sku); +END; +$$; + +-- ── 6. grants ─────────────────────────────────────────────────────────────────────────────────── +GRANT EXECUTE ON FUNCTION public.entitlement_balance_earn(TEXT, TEXT, TEXT, INT, TEXT) TO anon, authenticated; + +-- Never client-reachable: an ad reward is only as trustworthy as the signature the SSV callback +-- checked, and a client has no signature to offer. +REVOKE EXECUTE ON FUNCTION public.entitlement_balance_ad_reward(UUID, UUID, TEXT, INT, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_balance_ad_reward(UUID, UUID, TEXT, INT, TEXT) TO service_role; + +REVOKE EXECUTE ON FUNCTION public.entitlement_activity_earned_today(UUID, UUID, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_activity_earned_today(UUID, UUID, TEXT) TO service_role; + +COMMENT ON FUNCTION public.entitlement_balance_earn(TEXT, TEXT, TEXT, INT, TEXT) IS + 'CLIENT-FACING activity earn. Tenant from the publishable key, subject from the device token. ' + 'Clamped to tenant_products.daily_earn_cap per subject per UTC day; a NULL cap means this sku ' + 'cannot be earned by activity at all. Idempotent on the caller''s key.'; +COMMENT ON FUNCTION public.entitlement_balance_ad_reward(UUID, UUID, TEXT, INT, TEXT) IS + 'SERVICE-ROLE ad reward. Called ONLY by the ad-network server-side-verification callback, which ' + 'has already checked the network''s signature. Deliberately has no client-callable overload.'; diff --git a/supabase/migrations/162_verified_device_identity.sql b/supabase/migrations/162_verified_device_identity.sql new file mode 100644 index 00000000..4bf92925 --- /dev/null +++ b/supabase/migrations/162_verified_device_identity.sql @@ -0,0 +1,287 @@ +-- 162 — a device may only reach a verified subject's MONEY with a verified identity. +-- +-- THE HOLE THIS CLOSES +-- `register_device(p_email, …)` is granted to `anon, authenticated` and resolves the subject from a +-- CLIENT-SUPPLIED email. `entitlement_subject_resolve` matches on (kind, provider, value) and +-- ignores verified-ness for the lookup, so an unverified claim returns the EXISTING subject — +-- including one created by a real, verified sign-in. With the publishable key (which ships in every +-- app binary) that is: +-- +-- register_device(p_email: 'victim@example.com') -> device_token bound to the victim's subject +-- -> check_premium_with_device -> inherits their premium +-- -> entitlement_balance_get/spend -> reads and SPENDS their balance +-- +-- Migration 159 closed exactly this on the balance RPCs (a caller may no longer name a subject ID). +-- The REGISTRATION path still accepts a subject's EMAIL. Same class, other door. +-- +-- WHY THE FIX IS NOT "REFUSE UNVERIFIED CLAIMS" +-- The legitimate flow has the same shape: an app signs a user in with Google, reads their email, +-- and calls register_device with it. From this server's side that is an unverified claim on a +-- verified identity. Refusing it would break every signed-in user on every consumer. An +-- impersonation hole cannot be closed without an AUTHENTICATION MECHANISM — so this migration adds +-- one, and gates only the new money surface on it. +-- +-- WHAT THIS MIGRATION DOES +-- 1. tenants.auth_issuer_url — where a consumer's identity tokens can be VERIFIED (its JWKS). +-- 2. registered_devices.identity_verified — did this device prove who it is? +-- 3. The BALANCE RPCs refuse an unverified device when the subject has a verified identity. +-- +-- WHAT IT DELIBERATELY DOES NOT DO +-- It does not change `check_premium_with_device`. Premium inheritance by email is the SAME hole and +-- is NOT fixed here, because every existing paying user reaches premium through the unverified email +-- path and gating it today would lock them out. Premium moves behind the token once consumers have +-- shipped an SDK that sends one — deliberately a later step, not an oversight. The balance can be +-- gated NOW precisely because it is new: no consumer has shipped a balance yet, so there is nobody +-- to break. +-- +-- Idempotent throughout. + +-- ── 1. where a tenant's identity tokens are verified ──────────────────────────────────────────── +ALTER TABLE public.tenants + ADD COLUMN IF NOT EXISTS auth_issuer_url TEXT; + +COMMENT ON COLUMN public.tenants.auth_issuer_url IS + 'Base URL of the consumer app''s auth issuer, used to fetch its public JWKS and VERIFY identity ' + 'tokens (a Supabase project: https://.supabase.co). NULL means this tenant has not enabled ' + 'verified identity — its devices stay unverified and cannot reach a verified subject''s balance. ' + 'Asymmetric (JWKS) on purpose: PayCraft never holds a consumer''s signing secret.'; + +-- ── 2. did this device prove who it is ────────────────────────────────────────────────────────── +ALTER TABLE public.registered_devices + ADD COLUMN IF NOT EXISTS identity_verified BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.registered_devices.identity_verified IS + 'True only when this device registered by presenting an identity token this server VERIFIED ' + 'against the tenant''s auth_issuer_url. A client cannot set it: register_device is reachable from ' + 'anon and never writes it true. Only the identity-verify edge function (service_role) does.'; + +-- ── 3. does this subject have anything worth protecting ───────────────────────────────────────── +-- "Verified" here means an identity this server established itself — an OAuth/auth_user sign-in or +-- a store-attested account id. A subject with none of those has nothing to impersonate: it was +-- minted from the same kind of unverified claim the caller is making now. +CREATE OR REPLACE FUNCTION public.entitlement_subject_is_verified(p_subject UUID) +RETURNS BOOLEAN +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM entitlement_identities + WHERE subject_id = p_subject AND verified = true + ); +$$; + +-- ── 4. the gate ───────────────────────────────────────────────────────────────────────────────── +-- Resolve a device token to its subject, REFUSING when the device is unverified and the subject is. +-- Returns NULL for an unknown/inactive token (same contract as entitlement_subject_of_token) and +-- RAISES for the impersonation case, because the two deserve different answers: an unknown token is +-- an ordinary cold start, an unverified device reaching a verified subject's money is an attack or +-- a stale client, and silently returning 0 would teach the caller it had simply spent nothing. +CREATE OR REPLACE FUNCTION public.entitlement_subject_of_verified_token( + p_server_token TEXT, + p_tenant UUID +) +RETURNS UUID +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_subject UUID; + v_devverif BOOLEAN; +BEGIN + SELECT subject_id, identity_verified INTO v_subject, v_devverif + FROM registered_devices + WHERE device_token = p_server_token + AND is_active = true + AND (tenant_id IS NOT DISTINCT FROM p_tenant) + LIMIT 1; + + IF v_subject IS NULL THEN + RETURN NULL; + END IF; + + IF NOT v_devverif AND entitlement_subject_is_verified(v_subject) THEN + RAISE EXCEPTION + 'device identity not verified for a verified subject — re-register with an identity token' + USING ERRCODE = 'insufficient_privilege'; + END IF; + + RETURN v_subject; +END; +$$; + +-- ── 5. point the MONEY RPCs at the gated resolver ─────────────────────────────────────────────── +-- Bodies are otherwise identical to 159/161; only the subject lookup changes. +CREATE OR REPLACE FUNCTION public.entitlement_balance_get( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT +) +RETURNS INT +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; +BEGIN + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + v_subject := entitlement_subject_of_verified_token(p_server_token, v_tenant); + IF v_subject IS NULL THEN + RETURN 0; -- unchanged: an unresolvable token reads as an empty balance + END IF; + RETURN public.entitlement_balance_get(p_api_key, v_subject, p_sku); +END; +$$; + +CREATE OR REPLACE FUNCTION public.entitlement_balance_spend( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; +BEGIN + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + v_subject := entitlement_subject_of_verified_token(p_server_token, v_tenant); + IF v_subject IS NULL THEN + RAISE EXCEPTION 'unknown or inactive device token' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + RETURN public.entitlement_balance_spend(p_api_key, v_subject, p_sku, p_quantity, p_idempotency_key); +END; +$$; + +CREATE OR REPLACE FUNCTION public.entitlement_balance_earn( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; + v_cap INT; + v_earned INT; + v_grant INT; +BEGIN + IF p_quantity IS NULL OR p_quantity < 1 THEN + RAISE EXCEPTION 'quantity must be >= 1' USING ERRCODE = 'check_violation'; + END IF; + IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN + RAISE EXCEPTION 'idempotency_key is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + v_subject := entitlement_subject_of_verified_token(p_server_token, v_tenant); + IF v_subject IS NULL THEN + RAISE EXCEPTION 'unknown or inactive device token' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + SELECT daily_earn_cap INTO v_cap + FROM tenant_products + WHERE tenant_id = v_tenant AND sku = p_sku AND active; + + IF v_cap IS NULL THEN + RAISE EXCEPTION 'sku % does not allow activity earning (set tenant_products.daily_earn_cap)', p_sku + USING ERRCODE = 'check_violation'; + END IF; + + v_earned := entitlement_activity_earned_today(v_tenant, v_subject, p_sku); + v_grant := LEAST(p_quantity, GREATEST(v_cap - v_earned, 0)); + + IF v_grant > 0 THEN + INSERT INTO entitlement_balance_ledger + (tenant_id, subject_id, sku, delta, reason, stable_txn_id, idempotency_key) + VALUES + (v_tenant, v_subject, p_sku, v_grant, 'activity', NULL, btrim(p_idempotency_key)) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; + END IF; + + RETURN public.entitlement_balance_get(p_api_key, v_subject, p_sku); +END; +$$; + +-- ── 6. the verified-registration writer (service_role only) ───────────────────────────────────── +-- Called ONLY by the identity-verify edge function, AFTER it has verified the token's signature +-- against the tenant's JWKS. It takes the already-verified claims, never a raw token: keeping JWT +-- verification out of plpgsql means the trust boundary is one auditable TypeScript file. +CREATE OR REPLACE FUNCTION public.register_verified_device( + p_tenant_id UUID, + p_auth_sub TEXT, + p_email TEXT, + p_device_token TEXT, + p_platform TEXT, + p_device_name TEXT DEFAULT NULL +) +RETURNS UUID +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_subject UUID; +BEGIN + IF p_auth_sub IS NULL OR btrim(p_auth_sub) = '' THEN + RAISE EXCEPTION 'auth subject is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + -- auth_user, not email: the provider's immutable subject id is the identity, and an email is a + -- mutable alias a user can change. Marked verified because the signature was checked. + v_subject := entitlement_subject_resolve('auth_user', NULL, p_auth_sub, true); + + -- Link the email too, also verified, so a purchase made under that address finds this subject. + IF p_email IS NOT NULL AND btrim(p_email) <> '' THEN + PERFORM entitlement_identity_link(v_subject, 'email', NULL, p_email, true); + END IF; + + INSERT INTO registered_devices + (device_token, email, platform, device_name, is_active, identity_verified, subject_id, tenant_id) + VALUES + (p_device_token, COALESCE(p_email, p_auth_sub), p_platform, p_device_name, true, true, v_subject, p_tenant_id) + ON CONFLICT (device_token) DO UPDATE + SET subject_id = EXCLUDED.subject_id, + tenant_id = EXCLUDED.tenant_id, + is_active = true, + identity_verified = true; + + RETURN v_subject; +END; +$$; + +-- ── 7. grants ─────────────────────────────────────────────────────────────────────────────────── +GRANT EXECUTE ON FUNCTION public.entitlement_balance_get(TEXT, TEXT, TEXT) TO anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_balance_spend(TEXT, TEXT, TEXT, INT, TEXT) TO anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_balance_earn(TEXT, TEXT, TEXT, INT, TEXT) TO anon, authenticated; + +-- Never client-reachable: a client that could write identity_verified would defeat the whole gate. +REVOKE EXECUTE ON FUNCTION public.register_verified_device(UUID, TEXT, TEXT, TEXT, TEXT, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.register_verified_device(UUID, TEXT, TEXT, TEXT, TEXT, TEXT) TO service_role; + +REVOKE EXECUTE ON FUNCTION public.entitlement_subject_of_verified_token(TEXT, UUID) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_subject_of_verified_token(TEXT, UUID) TO service_role; +REVOKE EXECUTE ON FUNCTION public.entitlement_subject_is_verified(UUID) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_subject_is_verified(UUID) TO service_role; + +COMMENT ON FUNCTION public.entitlement_subject_of_verified_token(TEXT, UUID) IS + 'Device token -> subject, REFUSING an unverified device when the subject has a verified identity. ' + 'NULL for an unknown token; RAISES insufficient_privilege for the impersonation case.'; diff --git a/supabase/migrations/163_ad_reward_config.sql b/supabase/migrations/163_ad_reward_config.sql new file mode 100644 index 00000000..b57f353f --- /dev/null +++ b/supabase/migrations/163_ad_reward_config.sql @@ -0,0 +1,127 @@ +-- 163 — per-sku ad-reward configuration. +-- +-- The `ad-reward` edge function must decide HOW MUCH a watched ad is worth, and the only safe place +-- for that is the catalogue. AdMob's callback carries a `reward_amount`, but it is configured in +-- the AdMob console — outside this system, changeable by anyone with console access, and attacker- +-- adjacent for a self-hosted deployment. Taking it verbatim would let an ad-unit misconfiguration +-- set the price of the tenant's currency. +-- +-- Same discipline as `grant_quantity` for purchases (157) and `daily_earn_cap` for activity (161): +-- the payload says WHAT HAPPENED, the catalogue says WHAT IT IS WORTH. +-- +-- NULL/0 = ad rewards are DISABLED for this sku, which is the default for every existing row. A +-- tenant opts in deliberately, because enabling it turns watched attention into currency. +-- +-- Idempotent. + +ALTER TABLE public.tenant_products + ADD COLUMN IF NOT EXISTS ad_reward_quantity INT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'tenant_products_ad_reward_quantity_positive' + ) THEN + ALTER TABLE public.tenant_products + ADD CONSTRAINT tenant_products_ad_reward_quantity_positive + CHECK (ad_reward_quantity IS NULL OR ad_reward_quantity >= 1); + END IF; +END $$; + +COMMENT ON COLUMN public.tenant_products.ad_reward_quantity IS + 'Units credited per VERIFIED rewarded-ad view (reason=ad_reward). NULL/0 disables ad rewards for ' + 'this sku — the default. Deliberately NOT read from the ad network''s callback: reward_amount is ' + 'configured in the ad console, outside this system, so trusting it would let that console set the ' + 'price of this currency.'; + +-- A daily ceiling for ad rewards too. Without one, an automated viewer farms currency at whatever +-- rate the ad network will serve — the activity cap's exact problem, with a different mint. +ALTER TABLE public.tenant_products + ADD COLUMN IF NOT EXISTS daily_ad_reward_cap INT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'tenant_products_daily_ad_reward_cap_positive' + ) THEN + ALTER TABLE public.tenant_products + ADD CONSTRAINT tenant_products_daily_ad_reward_cap_positive + CHECK (daily_ad_reward_cap IS NULL OR daily_ad_reward_cap >= 1); + END IF; +END $$; + +COMMENT ON COLUMN public.tenant_products.daily_ad_reward_cap IS + 'Max units creditable by reason=ad_reward per subject per UTC day. NULL = uncapped, which is only ' + 'safe when the ad network''s own frequency capping is trusted to be the limit.'; + +-- ── enforce the ad cap in the same place the activity cap is enforced ─────────────────────────── +-- Clamped, not rejected: a user at their ceiling has already watched the ad, and erroring would +-- take their attention and give nothing while telling them something broke. +CREATE OR REPLACE FUNCTION public.entitlement_ad_earned_today( + p_tenant_id UUID, + p_subject_id UUID, + p_sku TEXT +) +RETURNS INT +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE(SUM(delta), 0)::INT + FROM entitlement_balance_ledger + WHERE tenant_id = p_tenant_id + AND subject_id = p_subject_id + AND sku = p_sku + AND reason = 'ad_reward' + AND occurred_at >= date_trunc('day', now() AT TIME ZONE 'utc'); +$$; + +CREATE OR REPLACE FUNCTION public.entitlement_balance_ad_reward( + p_tenant_id UUID, + p_subject_id UUID, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_cap INT; + v_earned INT; + v_grant INT; +BEGIN + IF p_quantity IS NULL OR p_quantity < 1 THEN + RAISE EXCEPTION 'quantity must be >= 1' USING ERRCODE = 'check_violation'; + END IF; + IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN + RAISE EXCEPTION 'idempotency_key is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + SELECT daily_ad_reward_cap INTO v_cap + FROM tenant_products + WHERE tenant_id = p_tenant_id AND sku = p_sku AND active; + + IF v_cap IS NULL THEN + v_grant := p_quantity; -- uncapped: the network's frequency cap is the limit + ELSE + v_earned := entitlement_ad_earned_today(p_tenant_id, p_subject_id, p_sku); + v_grant := LEAST(p_quantity, GREATEST(v_cap - v_earned, 0)); + END IF; + + IF v_grant > 0 THEN + INSERT INTO entitlement_balance_ledger + (tenant_id, subject_id, sku, delta, reason, stable_txn_id, idempotency_key) + VALUES + (p_tenant_id, p_subject_id, p_sku, v_grant, 'ad_reward', NULL, btrim(p_idempotency_key)) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; + END IF; + + RETURN public.entitlement_balance_of_tenant(p_tenant_id, p_subject_id, p_sku); +END; +$$; + +REVOKE EXECUTE ON FUNCTION public.entitlement_balance_ad_reward(UUID, UUID, TEXT, INT, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_balance_ad_reward(UUID, UUID, TEXT, INT, TEXT) TO service_role; +REVOKE EXECUTE ON FUNCTION public.entitlement_ad_earned_today(UUID, UUID, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_ad_earned_today(UUID, UUID, TEXT) TO service_role; diff --git a/supabase/migrations/164_grant_sku_currency.sql b/supabase/migrations/164_grant_sku_currency.sql new file mode 100644 index 00000000..e7f8894c --- /dev/null +++ b/supabase/migrations/164_grant_sku_currency.sql @@ -0,0 +1,150 @@ +-- 164 — a product grants units of a CURRENCY, which is not the same thing as the product. +-- +-- THE GAP +-- Every credit path writes `entitlement_balance_ledger.sku = `. For a single +-- consumable that is right. For a CURRENCY sold in several sizes it is not: three coin packs +-- +-- cappy_ripples_300 -> 300 units of sku 'cappy_ripples_300' +-- cappy_ripples_800 -> 800 units of sku 'cappy_ripples_800' +-- cappy_ripples_1800 -> 1800 units of sku 'cappy_ripples_1800' +-- +-- produce THREE separate balances, none of which is "how many Ripples do I have". The user buys a +-- bigger pack and their coins do not add up. Spending is worse: the host would have to pick which +-- pack's balance to debit and handle the case where no single one covers the price. +-- +-- Found by wiring the first real consumer (mbs/cappy, 2026-10-22) rather than by review — the +-- single-pack case this was built against never exposed it. +-- +-- THE FIX +-- `grant_sku` names the currency a product credits. It defaults to the product's own sku, so every +-- existing row keeps its exact current behaviour and no deployed app changes meaning. A family of +-- packs sets all of them to one shared currency id, and the balance finally adds up. +-- +-- cappy_ripples_300 / _800 / _1800 grant_sku = 'ripples' +-- ad reward sku = 'ripples' +-- focus-session activity earn sku = 'ripples' +-- spend a cosmetic sku = 'ripples' +-- +-- The currency id is NOT itself a product and deliberately has no row in tenant_products: it is a +-- ledger partition key, and requiring a product row for it would mean inventing a sellable thing +-- nobody sells. The consequence is that `daily_earn_cap` and `ad_reward_quantity` — which are read +-- per-sku — must be declared on a row whose `grant_sku` IS that currency; the helpers below resolve +-- a currency back to its configuring product so a caller never has to know which one. +-- +-- Idempotent. + +ALTER TABLE public.tenant_products + ADD COLUMN IF NOT EXISTS grant_sku TEXT; + +COMMENT ON COLUMN public.tenant_products.grant_sku IS + 'The CURRENCY this product credits, when it is not the product itself. NULL (default) means the ' + 'product''s own sku — unchanged behaviour. Several pack sizes of one currency share a value here ' + 'so their balances add up instead of being three unrelated numbers.'; + +-- ── resolve a product to the currency it credits ──────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.entitlement_currency_of( + p_tenant_id UUID, + p_sku TEXT +) +RETURNS TEXT +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE(NULLIF(btrim(grant_sku), ''), sku) + FROM tenant_products + WHERE tenant_id = p_tenant_id AND sku = p_sku + LIMIT 1; +$$; + +-- ── which product carries the earn/ad configuration for a CURRENCY ───────────────────────────── +-- A currency has no product row of its own, so "what is the daily cap for 'ripples'" has to resolve +-- through whichever product declares it. Deterministic by sku so two configuring rows cannot make +-- the answer depend on insert order; a tenant that declares conflicting caps gets the first by name +-- and that is a configuration error to surface, not a coin flip to hide. +CREATE OR REPLACE FUNCTION public.entitlement_currency_config( + p_tenant_id UUID, + p_currency TEXT +) +RETURNS TABLE (daily_earn_cap INT, ad_reward_quantity INT, daily_ad_reward_cap INT) +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT tp.daily_earn_cap, tp.ad_reward_quantity, tp.daily_ad_reward_cap + FROM tenant_products tp + WHERE tp.tenant_id = p_tenant_id + AND tp.active + AND COALESCE(NULLIF(btrim(tp.grant_sku), ''), tp.sku) = p_currency + AND (tp.daily_earn_cap IS NOT NULL + OR tp.ad_reward_quantity IS NOT NULL + OR tp.daily_ad_reward_cap IS NOT NULL) + ORDER BY tp.sku + LIMIT 1; +$$; + +-- ── the activity earn now reads its cap through the currency ─────────────────────────────────── +CREATE OR REPLACE FUNCTION public.entitlement_balance_earn( + p_server_token TEXT, + p_api_key TEXT, + p_sku TEXT, + p_quantity INT, + p_idempotency_key TEXT +) +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant UUID; + v_subject UUID; + v_cap INT; + v_earned INT; + v_grant INT; +BEGIN + IF p_quantity IS NULL OR p_quantity < 1 THEN + RAISE EXCEPTION 'quantity must be >= 1' USING ERRCODE = 'check_violation'; + END IF; + IF p_idempotency_key IS NULL OR btrim(p_idempotency_key) = '' THEN + RAISE EXCEPTION 'idempotency_key is required' USING ERRCODE = 'null_value_not_allowed'; + END IF; + + v_tenant := resolve_tenant(p_api_key); + IF v_tenant IS NULL THEN + RAISE EXCEPTION 'unknown or inactive api key' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + v_subject := entitlement_subject_of_verified_token(p_server_token, v_tenant); + IF v_subject IS NULL THEN + RAISE EXCEPTION 'unknown or inactive device token' USING ERRCODE = 'invalid_authorization_specification'; + END IF; + + -- p_sku is the CURRENCY here, not a product. Read the cap from whichever product declares it. + SELECT c.daily_earn_cap INTO v_cap + FROM entitlement_currency_config(v_tenant, p_sku) c; + + IF v_cap IS NULL THEN + RAISE EXCEPTION + 'currency % does not allow activity earning (set daily_earn_cap on a product whose grant_sku is %)', + p_sku, p_sku + USING ERRCODE = 'check_violation'; + END IF; + + v_earned := entitlement_activity_earned_today(v_tenant, v_subject, p_sku); + v_grant := LEAST(p_quantity, GREATEST(v_cap - v_earned, 0)); + + IF v_grant > 0 THEN + INSERT INTO entitlement_balance_ledger + (tenant_id, subject_id, sku, delta, reason, stable_txn_id, idempotency_key) + VALUES + (v_tenant, v_subject, p_sku, v_grant, 'activity', NULL, btrim(p_idempotency_key)) + ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; + END IF; + + RETURN public.entitlement_balance_get(p_api_key, v_subject, p_sku); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.entitlement_balance_earn(TEXT, TEXT, TEXT, INT, TEXT) TO anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entitlement_currency_of(UUID, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_currency_of(UUID, TEXT) TO service_role; +REVOKE EXECUTE ON FUNCTION public.entitlement_currency_config(UUID, TEXT) FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.entitlement_currency_config(UUID, TEXT) TO service_role; diff --git a/supabase/migrations/165_billing_canary_webhook_verdicts.sql b/supabase/migrations/165_billing_canary_webhook_verdicts.sql new file mode 100644 index 00000000..b8c9bb10 --- /dev/null +++ b/supabase/migrations/165_billing_canary_webhook_verdicts.sql @@ -0,0 +1,189 @@ +-- 165 — continuous verdicts on whether each provider's purchase path is still working. +-- +-- WHAT THIS IS FOR +-- `webhook-health` answers "is this tenant's config shaped correctly" when someone asks it. +-- Nothing answers "has a provider stopped delivering purchases" unless someone asks, and nobody +-- asks until a customer complains. That is the gap this closes: a scheduled verdict per +-- (tenant, provider), emitted as an alert through the existing `tenant_alert_log` path. +-- +-- ── WHY THE THRESHOLDS LOOK TIMID ───────────────────────────────────────────────────────────── +-- Measured on production 2026-10-22: 9 tenants, 6 subscriptions total, 4 active, and every one of +-- them Stripe. Play, App Store and Razorpay are configured on 9/9/4 tenants and have produced +-- ZERO subscriptions. +-- +-- At that volume the obvious detector — "no webhook in 6 hours" — is noise, not signal. A healthy +-- tenant with one subscriber legitimately sees no webhook for a month. Shipping that detector +-- would produce a daily false alarm, and a daily false alarm is worse than no detector: it trains +-- the operator to ignore the one real alert when it comes. +-- +-- So each verdict is gated on having enough history to mean anything, and a thin baseline is +-- reported as `insufficient-baseline` — a STATUS, never an alert. This is deliberately the honest +-- shape rather than the impressive one. As volume grows the same thresholds start firing on their +-- own, because they are relative to each provider's own observed cadence rather than a fixed +-- interval someone guessed. +-- +-- ── THE FOUR VERDICTS, AND WHY EACH EARNS ITS PLACE ─────────────────────────────────────────── +-- failing — failures are volume-INDEPENDENT, so this is the one detector that +-- works at 4 subscriptions. A signature mismatch or a rotated key +-- fails every delivery, and 3 failures at >20% is already a broken +-- purchase path. +-- silent — needs history to judge. Fires at 3x the provider's own longest +-- observed gap, floored at 24h so a chatty provider cannot produce a +-- hair-trigger. +-- never-seen — a provider CONFIGURED but with no event EVER. At this volume that +-- is the single most likely real defect: a webhook registered in the +-- dashboard that was never reachable, which looks identical to "no +-- sales yet" until someone checks. Alerted once, then deduped. +-- insufficient-baseline — 1..4 events. Not an alert. Says "cannot judge yet" out loud instead +-- of silently passing, which is how a dead provider hides. +-- +-- Idempotent: CREATE OR REPLACE throughout; the cron schedule is guarded on pg_cron's presence +-- exactly as 037 does. + +-- ── 1. the verdicts ────────────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.billing_canary_webhook_verdicts() +RETURNS TABLE ( + tenant_id UUID, + owner_email TEXT, + provider TEXT, + verdict TEXT, + events_30d INT, + failures_30d INT, + last_success_at TIMESTAMPTZ, + longest_gap INTERVAL, + detail TEXT +) +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + WITH + -- Every (tenant, provider) a purchase could legitimately arrive on. Driven from + -- tenant_providers rather than from webhook_logs, because a provider that has NEVER delivered + -- is precisely the case worth catching — and it has no rows in webhook_logs to be found by. + configured AS ( + SELECT tp.tenant_id, tp.provider, t.owner_email + FROM tenant_providers tp + JOIN tenants t ON t.id = tp.tenant_id + ), + -- Gaps between consecutive SUCCESSFUL deliveries, per (tenant, provider). Successes only: + -- a run of failures must not be mistaken for healthy cadence. + gaps AS ( + SELECT wl.tenant_id, wl.provider, + wl.created_at - lag(wl.created_at) OVER ( + PARTITION BY wl.tenant_id, wl.provider ORDER BY wl.created_at + ) AS gap + FROM webhook_logs wl + WHERE wl.status = 'success' + AND wl.created_at > now() - INTERVAL '30 days' + ), + stats AS ( + SELECT c.tenant_id, c.provider, c.owner_email, + count(wl.id) FILTER ( + WHERE wl.created_at > now() - INTERVAL '30 days')::INT AS events_30d, + count(wl.id) FILTER ( + WHERE wl.status = 'failed' + AND wl.created_at > now() - INTERVAL '30 days')::INT AS failures_30d, + max(wl.created_at) FILTER (WHERE wl.status = 'success') AS last_success_at, + (SELECT max(g.gap) FROM gaps g + WHERE g.tenant_id = c.tenant_id AND g.provider = c.provider) AS longest_gap, + -- Distinguishes "never delivered" from "quiet lately". EVER, not 30d: a provider that + -- worked a year ago is a different problem from one that never worked at all. + EXISTS (SELECT 1 FROM webhook_logs w2 + WHERE w2.tenant_id = c.tenant_id AND w2.provider = c.provider) AS ever_seen + FROM configured c + LEFT JOIN webhook_logs wl + ON wl.tenant_id = c.tenant_id AND wl.provider = c.provider + GROUP BY c.tenant_id, c.provider, c.owner_email + ) + SELECT s.tenant_id, s.owner_email, s.provider, + CASE + -- Order matters: a FAILING provider is also technically silent, and the failure is the + -- actionable half (it names a cause), so it must win. + WHEN s.failures_30d >= 3 + AND s.events_30d > 0 + AND s.failures_30d::NUMERIC / s.events_30d > 0.2 THEN 'failing' + WHEN NOT s.ever_seen THEN 'never-seen' + WHEN s.events_30d >= 5 + AND s.last_success_at IS NOT NULL + AND now() - s.last_success_at > + GREATEST(COALESCE(s.longest_gap, INTERVAL '0') * 3, + INTERVAL '24 hours') THEN 'silent' + WHEN s.events_30d BETWEEN 1 AND 4 THEN 'insufficient-baseline' + WHEN s.events_30d = 0 AND s.ever_seen THEN 'insufficient-baseline' + ELSE 'ok' + END AS verdict, + s.events_30d, s.failures_30d, s.last_success_at, s.longest_gap, + CASE + WHEN s.failures_30d >= 3 AND s.events_30d > 0 + AND s.failures_30d::NUMERIC / s.events_30d > 0.2 + THEN s.failures_30d || '/' || s.events_30d || ' deliveries failed in 30d' + WHEN NOT s.ever_seen + THEN 'provider configured but has never delivered a webhook — verify the endpoint is ' + || 'registered AND reachable (this looks identical to "no sales yet")' + WHEN s.events_30d >= 5 AND s.last_success_at IS NOT NULL + AND now() - s.last_success_at > + GREATEST(COALESCE(s.longest_gap, INTERVAL '0') * 3, INTERVAL '24 hours') + THEN 'no successful delivery for ' || (now() - s.last_success_at)::TEXT + || ' (3x its own longest 30d gap of ' || COALESCE(s.longest_gap::TEXT, 'n/a') || ')' + WHEN s.events_30d BETWEEN 1 AND 4 OR (s.events_30d = 0 AND s.ever_seen) + THEN 'only ' || s.events_30d || ' event(s) in 30d — too thin to judge silence; ' + || 'failure detection still active' + ELSE 'healthy' + END AS detail + FROM stats s; +$$; + +COMMENT ON FUNCTION public.billing_canary_webhook_verdicts() IS + 'Per-(tenant,provider) verdict on whether the purchase path is still delivering. ' + 'Volume-guarded: see migration 165 for why the thresholds are relative, not fixed.'; + +-- ── 2. emit, with dedup ────────────────────────────────────────────────────────────────────────── +-- Reuses tenant_alert_log + the tenant-alerts function's existing email path rather than adding a +-- second notification channel. `idx_alert_log_dedup` was built for exactly this query shape. +CREATE OR REPLACE FUNCTION public.billing_canary_emit_alerts() +RETURNS INT +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_alerted INT := 0; + r RECORD; + v_type TEXT; +BEGIN + FOR r IN + SELECT * FROM billing_canary_webhook_verdicts() + WHERE verdict IN ('failing', 'silent', 'never-seen') + LOOP + v_type := 'canary_' || replace(r.verdict, '-', '_'); + + -- One alert per (tenant, provider-class) per 24h. Without this a persistently broken provider + -- emails the owner on every cron tick until they fix it, which is how an alert channel gets + -- muted — and a muted channel is indistinguishable from no channel. + CONTINUE WHEN EXISTS ( + SELECT 1 FROM tenant_alert_log + WHERE tenant_id = r.tenant_id + AND alert_type = v_type + AND sent_at > now() - INTERVAL '24 hours' + ); + + INSERT INTO tenant_alert_log(tenant_id, alert_type, recipient) + VALUES (r.tenant_id, v_type, r.owner_email); + v_alerted := v_alerted + 1; + + RAISE NOTICE 'billing canary: % % -> % (%)', r.tenant_id, r.provider, r.verdict, r.detail; + END LOOP; + RETURN v_alerted; +END; +$$; + +-- ── 3. schedule ────────────────────────────────────────────────────────────────────────────────── +-- Hourly, not daily. The daily cadence in 037 suits a grace-period sweep, where a day of latency +-- changes nothing. A broken purchase path costs money every hour it goes unnoticed, and the dedup +-- above means an hourly check still sends at most one email a day. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + PERFORM cron.schedule('paycraft_billing_canary', '7 * * * *', + $cron$ SELECT billing_canary_emit_alerts(); $cron$); + END IF; +END $$;