Repository navigation
feat(paycraft): consumables end-to-end — grant path, drain, store sync; fix: store-subscription tenant attribution; fix(security): resolve balance subject server-side #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
e714776
856fc45
47410f8
89dbb75
bacf115
8e37711
f0d8e3e
cc81970
d17c43b
944931e
6e5a2c0
be6c8f2
0a33762
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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." |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Comment on lines
+95
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The comments at lines 76-78 and 98-100 state that the server credit arrives out of band after the ad closes. The code calls Nothing else triggers a reconcile until the next app start or foreground. The user sees no reward and believes the ad paid nothing. This is the outcome the kdoc warns about. Reconcile a small, bounded number of times with a delay. Stop when the balance changes. A daily cap can leave the balance unchanged, so keep the loop bounded. Proposed fix- balances.reconcile(sku)
- return balances.balanceOf(sku)
+ val before = balances.balanceOf(sku)
+ repeat(RECONCILE_ATTEMPTS) { attempt ->
+ balances.reconcile(sku)
+ if (balances.balanceOf(sku) != before) return balances.balanceOf(sku)
+ if (attempt < RECONCILE_ATTEMPTS - 1) delay(RECONCILE_DELAY_MS)
+ }
+ return balances.balanceOf(sku)Define 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.