Skip to content

Commit 24d2081

Browse files
paycraft production level, supporting default currency if country currency does not exist (#121)
* chore: initialize session branch — paycraft production level, supporting default currency if country currency does not exist * chore(source): update cli/package.json cli/src/commands/init.ts cli/src/index.ts * chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.android.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/billing/NativeBillingClient.kt * chore(source): update dashboard/app/(dashboard)/providers/app-store/page.tsx dashboard/app/(dashboard)/providers/google-play/page.tsx dashboard/app/api/products/[id]/sync/route.ts * chore(source): update cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/billing/PlayBillingNativeClient.android.kt cmp-paycraft/src/androidMain/kotlin/com/mobilebytelabs/paycraft/di/PayCraftPlayBillingModule.android.kt cmp-paycraft/src/commonMain/kotlin/com/mobilebytelabs/paycraft/PayCraft.kt * chore(source): update cmp-paycraft/build.gradle.kts cmp-paycraft/src/macosMain/kotlin/com/mobilebytelabs/paycraft/PayCraftPlatform.macos.kt cmp-paycraft/src/macosMain/kotlin/com/mobilebytelabs/paycraft/platform/DeviceFingerprint.macos.kt * chore(source): update CHANGELOG.md gradle.properties --------- Co-authored-by: Mobile Byte Labs <mobilebytelabs@gmail.com>
1 parent b6cf357 commit 24d2081

186 files changed

Lines changed: 7144 additions & 6578 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog
22

3+
## [2.2.0] — Google Play Payments-policy compliance + native billing
4+
5+
Makes PayCraft compliant with Google Play's Payments policy: on Android, digital-subscription checkout now transacts through **Google Play Billing** instead of opening an external web payment page (the anti-steering violation that flagged consumer apps such as Reels Downloader `com.sensei.social`). Web/link-out remains the path on web/desktop and for physical goods.
6+
7+
### Added
8+
9+
- **Hybrid native + web checkout routing** — `billing/CheckoutLane.kt` (`resolveCheckoutLane(platform, plan, isDigital)`): Android + digital + a configured `playProductId` → native Google Play Billing (`NativeBillingClient.purchase()`); web/desktop/iOS or physical → existing web payment link. `openUrl()` is now reachable only in the `Web` lane.
10+
- **Native Google Play Billing (v8) + StoreKit2 clients** wired into checkout (`PlayBillingNativeClient`, `StoreKit2NativeBillingClient` + Swift shim).
11+
- **Anti-steering guard** — on Android + digital with no `playProductId`, the paywall fails closed (`BillingState.Error`) and never opens a browser.
12+
- **Server entitlement grant** — `supabase/functions/register-play-purchase/` validates the Play purchase token (Play Developer API) and reconciles the entitlement.
13+
- **Store product IDs in config** — `play_product_id` / `app_store_product_id` on `ProductDto` + `BillingPlan`, flowed DB → config → SDK (migration `073`).
14+
- **Dashboard store-product sync** — per-product Google Play / App Store ID fields; per-tenant encrypted Play service-account + App Store Connect `.p8` credentials (migration `074`); auto-create/sync subscriptions on Google Play (`monetization.subscriptions`) + App Store Connect (`subscriptions`) with write-back.
15+
16+
### Changed
17+
18+
- Dropped `macosX64`/`macosArm64` targets (Store5 5.1.0-alpha08 ships no macOS artifact; macOS is not a shipped platform — re-add when Store5 supports it).
19+
320
## [Unreleased] — paycraft-v2-production-readiness epic
421

522
Closes the 5-phase production-readiness epic spanning vault, domain, DR, observability, and Maven publish gates. See `plan-layer/project-plans/mbs/PayCraft/active/paycraft-v2-production-readiness/PLAN.md` for the full spec.

‎cli/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"scripts": {
99
"build": "tsc",
1010
"dev": "ts-node src/index.ts",
11-
"prepublishOnly": "npm run build"
11+
"prepublishOnly": "npm run build",
12+
"smoke:onboarding": "bash ../scripts/paycraft-onboarding-smoke.sh"
1213
},
1314
"keywords": [
1415
"paycraft",

‎cli/src/commands/init.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@ import * as path from "path";
77
interface InitOptions {
88
cloud?: boolean;
99
selfHosted?: boolean;
10+
apiKey?: string;
11+
provider?: string;
12+
platform?: string;
13+
out?: string;
14+
yes?: boolean;
1015
}
1116

1217
export async function init(options: InitOptions) {
1318
console.log(chalk.bold("\n PayCraft Setup\n"));
1419

20+
// Non-interactive fast path (AC10 — `npx paycraft init --cloud --api-key … --yes --out …`).
21+
// Powers scripts/paycraft-onboarding-smoke.sh: init → PayCraft.initialize(apiKey) → first read.
22+
if (options.apiKey) {
23+
setupCloudNonInteractive(options);
24+
return;
25+
}
26+
1527
// Step 1: Hosting mode
1628
let mode = options.cloud ? "cloud" : options.selfHosted ? "self-hosted" : "";
1729
if (!mode) {
@@ -171,6 +183,37 @@ async function setupSelfHosted() {
171183
console.log("");
172184
}
173185

186+
function setupCloudNonInteractive(options: InitOptions) {
187+
const apiKey = options.apiKey!;
188+
if (!apiKey.startsWith("pk_")) {
189+
console.error(chalk.red(" API key should start with pk_test_ or pk_live_"));
190+
process.exit(1);
191+
}
192+
const provider = options.provider || "stripe";
193+
const platform = options.platform || "Multiplatform (all)";
194+
const isTest = apiKey.startsWith("pk_test_");
195+
const outDir = path.resolve(options.out || ".");
196+
fs.mkdirSync(outDir, { recursive: true });
197+
198+
const config = {
199+
mode: "cloud",
200+
apiKey,
201+
provider,
202+
platform,
203+
testMode: isTest,
204+
generatedAt: new Date().toISOString(),
205+
initSnippet: `PayCraft.initialize(apiKey = "${apiKey}")`,
206+
};
207+
const configPath = path.join(outDir, "paycraft.config.json");
208+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
209+
210+
const snippet = generateKotlinConfig({ mode: "cloud", apiKey, provider, isTest });
211+
fs.writeFileSync(path.join(outDir, "PayCraftInit.kt.txt"), snippet + "\n");
212+
213+
console.log(chalk.green(` Wrote ${configPath}`));
214+
console.log(chalk.green(` One-line boot: PayCraft.initialize(apiKey = "${apiKey}")`));
215+
}
216+
174217
function generateKotlinConfig(opts: {
175218
mode: string;
176219
apiKey?: string;

‎cli/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ program
1717
.description("Initialize PayCraft in your project (interactive setup)")
1818
.option("--cloud", "Configure for PayCraft Cloud (hosted backend)")
1919
.option("--self-hosted", "Configure for self-hosted Supabase")
20+
.option("--api-key <key>", "PayCraft Cloud API key (pk_test_… / pk_live_…) — enables non-interactive init")
21+
.option("--provider <name>", "Payment provider (non-interactive)", "stripe")
22+
.option("--platform <name>", "Primary platform (non-interactive)", "Multiplatform (all)")
23+
.option("--out <dir>", "Output directory for the generated config", ".")
24+
.option("--yes", "Assume yes / write files without prompting (non-interactive)")
2025
.action(init);
2126

2227
program

‎cmp-paycraft/build.gradle.kts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ kotlin {
3636
iosArm64()
3737
iosSimulatorArm64()
3838

39-
macosX64()
40-
macosArm64()
39+
// macOS targets dropped 2026-07-24: Store5 (5.1.0-alpha08, the offline entitlement
40+
// cache from E4) publishes no macos_x64/macos_arm64 variant, so `commonMain`'s store5
41+
// dependency cannot resolve for macOS → "Compile All Targets" fails. macOS is not a
42+
// shipped PayCraft platform (native billing is Android/iOS per D13; the sample-app does
43+
// not target macOS; no consumer ships macOS). Re-add macosX64()/macosArm64() + src/macosMain
44+
// once Store5 ships macOS artifacts, or move store5 into a non-macOS intermediate source set.
4145

4246
js {
4347
browser()
@@ -74,6 +78,13 @@ kotlin {
7478

7579
// Lifecycle (ViewModel + collectAsStateWithLifecycle)
7680
implementation(libs.lifecycle.viewmodel)
81+
// SavedStateHandle is referenced at LINK time by koin's ViewModel factory on Kotlin/Native.
82+
// Koin MUST match the lifecycle version Compose Multiplatform pulls (lifecycle 2.9.x, which
83+
// relocated SavedStateHandle to androidx.savedstate) — see the `koin = "4.1.0"` pin in
84+
// libs.versions.toml. A koin built against lifecycle 2.8.x hits
85+
// `IrLinkageError: No class found for symbol androidx.lifecycle/SavedStateHandle` the moment
86+
// a viewModelOf(...) resolves (e.g. PayCraftPaywallViewModel on paywall open).
87+
implementation(libs.lifecycle.viewmodel.savedstate)
7788
implementation(libs.lifecycle.runtime.compose)
7889

7990
// Logging
@@ -83,8 +94,24 @@ kotlin {
8394
implementation(libs.kotlinx.serialization.json)
8495
implementation(libs.kotlinx.coroutines.core)
8596

86-
// Settings (email persistence)
97+
// Settings (email persistence + offline entitlement SourceOfTruth — persistence/EntitlementDao.kt)
8798
implementation(libs.multiplatform.settings)
99+
100+
// Store5 read-through cache for offline-correct entitlement gating (D8, AC9 —
101+
// persistence/EntitlementCache.kt + core/EntitlementRepository.kt).
102+
implementation(libs.store5)
103+
104+
// SQLDelight SourceOfTruth: the offline SoT schema-of-record lives at
105+
// src/commonMain/sqldelight/com/mobilebytelabs/paycraft/db/Entitlement.sq
106+
// The Store5 SoT is backed today by the multiplatform-settings SettingsEntitlementDao
107+
// (all six targets, process-death-durable). The SQLDelight code-gen plugin + per-platform
108+
// drivers (android-driver / native-driver / sqlite-driver) are wired alongside the native
109+
// StoreKit2/Play clients in Phase 3 (E3) — at which point SqlDelightEntitlementDao becomes
110+
// a drop-in EntitlementDao. Enable then with:
111+
// plugins { alias(libs.plugins.sqldelight) }
112+
// sqldelight { databases { create("PayCraftDb") {
113+
// packageName.set("com.mobilebytelabs.paycraft.db") } } }
114+
// implementation(libs.sqldelight.coroutines.extensions)
88115
}
89116

90117
androidMain.dependencies {
@@ -93,6 +120,9 @@ kotlin {
93120
// androidx-startup hands the Application Context to PayCraftInitializer
94121
// before Application.onCreate runs — see PayCraftInitializer.kt.
95122
implementation("androidx.startup:startup-runtime:1.2.0")
123+
// Google Play Billing v8 — native Android IAP client (Phase 3, D8/D13).
124+
// PlayBillingNativeClient wraps BillingClient v8 (billing/NativeBillingClient.android.kt).
125+
implementation(libs.google.billing.ktx)
96126
}
97127

98128
iosMain.dependencies {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package com.mobilebytelabs.paycraft.billing
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.net.Uri
7+
import co.touchlab.kermit.Logger
8+
import com.android.billingclient.api.AcknowledgePurchaseParams
9+
import com.android.billingclient.api.BillingClient
10+
import com.android.billingclient.api.BillingClient.BillingResponseCode
11+
import com.android.billingclient.api.BillingClientStateListener
12+
import com.android.billingclient.api.BillingFlowParams
13+
import com.android.billingclient.api.BillingResult
14+
import com.android.billingclient.api.PendingPurchasesParams
15+
import com.android.billingclient.api.ProductDetails
16+
import com.android.billingclient.api.Purchase
17+
import com.android.billingclient.api.PurchasesUpdatedListener
18+
import com.android.billingclient.api.QueryProductDetailsParams
19+
import com.android.billingclient.api.QueryPurchasesParams
20+
import com.android.billingclient.api.acknowledgePurchase
21+
import com.android.billingclient.api.queryProductDetails
22+
import com.android.billingclient.api.queryPurchasesAsync
23+
import kotlinx.coroutines.flow.MutableSharedFlow
24+
import kotlinx.coroutines.flow.first
25+
import kotlinx.coroutines.suspendCancellableCoroutine
26+
import kotlinx.coroutines.sync.Mutex
27+
import kotlinx.coroutines.sync.withLock
28+
import kotlin.coroutines.resume
29+
30+
/**
31+
* Android [NativeBillingClient] over **Google Play Billing Library v8**
32+
* (`com.android.billingclient:billing-ktx:8.0.0`) — the Phase-3 native IAP client (D8/D13).
33+
*
34+
* v8 is required for all new apps by 2026-08-31 (GOAL Risks). This client is a *pure store
35+
* adapter* (D5): it drives the Play purchase / query / manage flows and emits [NativePurchase]
36+
* records (product id + `purchaseToken` + order id) for the Phase-2 reconciliation engine to
37+
* validate server-side (`subscriptionsv2.get`). It NEVER decides entitlement truth.
38+
*
39+
* Flow:
40+
* - [purchase] — lazily connects, `queryProductDetails` for the SUBS product, resolves the base
41+
* offer token, `launchBillingFlow`, awaits the [PurchasesUpdatedListener] callback, then
42+
* acknowledges the purchase (a subscription left un-acknowledged for 3 days is auto-refunded).
43+
* - [queryPurchases] / [restore] — `queryPurchasesAsync(SUBS)` for the signed-in Play account.
44+
* - [sync] — Play's re-link IS `queryPurchasesAsync` (there is no separate StoreKit-style sync).
45+
* - [manageSubscription] — deep-links the Play subscription centre (stores forbid programmatic
46+
* cancel — D7); a non-null product id targets the specific plan.
47+
*
48+
* DI: bind on Android via [com.mobilebytelabs.paycraft.di.paycraftPlayBillingModule], which
49+
* overrides the default `WebCheckoutNativeBillingClient` binding from `PayCraftModule`.
50+
*
51+
* @param context application context — used to construct the [BillingClient] and to launch the
52+
* Play subscription-centre intent for [manageSubscription].
53+
* @param activityProvider supplies the current foreground [Activity] required by
54+
* `launchBillingFlow`; returns null when no Activity is resumed (purchase then fails cleanly).
55+
*/
56+
class PlayBillingNativeClient(context: Context, private val activityProvider: () -> Activity?) : NativeBillingClient {
57+
58+
private val appContext: Context = context.applicationContext
59+
60+
/** Buffered so a purchase callback that arrives before [purchase] suspends is not dropped. */
61+
private val purchaseUpdates = MutableSharedFlow<PurchasesUpdate>(extraBufferCapacity = 1)
62+
63+
private val connectMutex = Mutex()
64+
65+
private val purchasesListener = PurchasesUpdatedListener { billingResult, purchases ->
66+
purchaseUpdates.tryEmit(PurchasesUpdate(billingResult, purchases.orEmpty()))
67+
}
68+
69+
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
70+
.enablePendingPurchases(
71+
PendingPurchasesParams.newBuilder()
72+
.enableOneTimeProducts()
73+
.enablePrepaidPlans()
74+
.build(),
75+
)
76+
.setListener(purchasesListener)
77+
.build()
78+
79+
override suspend fun purchase(productId: String): NativePurchaseResult {
80+
val connect = ensureConnected()
81+
if (connect.responseCode != BillingResponseCode.OK) {
82+
return NativePurchaseResult.Failed("Play billing connect failed: ${connect.debugMessage}")
83+
}
84+
val productDetails = queryProductDetails(productId)
85+
?: return NativePurchaseResult.Failed("Product not found on Play: $productId")
86+
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
87+
?: return NativePurchaseResult.Failed("No subscription offer for $productId")
88+
val activity = activityProvider()
89+
?: return NativePurchaseResult.Failed("No foreground Activity to launch the billing flow")
90+
91+
val flowParams = BillingFlowParams.newBuilder()
92+
.setProductDetailsParamsList(
93+
listOf(
94+
BillingFlowParams.ProductDetailsParams.newBuilder()
95+
.setProductDetails(productDetails)
96+
.setOfferToken(offerToken)
97+
.build(),
98+
),
99+
)
100+
.build()
101+
102+
val launch = billingClient.launchBillingFlow(activity, flowParams)
103+
if (launch.responseCode != BillingResponseCode.OK) {
104+
return NativePurchaseResult.Failed("launchBillingFlow failed: ${launch.debugMessage}")
105+
}
106+
107+
val update = purchaseUpdates.first()
108+
return when (update.billingResult.responseCode) {
109+
BillingResponseCode.OK -> {
110+
val purchase = update.purchases.firstOrNull { productId in it.products }
111+
?: update.purchases.firstOrNull()
112+
?: return NativePurchaseResult.Failed("Play reported OK with no Purchase")
113+
acknowledgeIfNeeded(purchase)
114+
NativePurchaseResult.Success(purchase.toNativePurchase())
115+
}
116+
BillingResponseCode.USER_CANCELED -> NativePurchaseResult.Cancelled
117+
else -> NativePurchaseResult.Failed("Purchase failed: ${update.billingResult.debugMessage}")
118+
}
119+
}
120+
121+
override suspend fun queryPurchases(): List<NativePurchase> {
122+
ensureConnected()
123+
val params = QueryPurchasesParams.newBuilder()
124+
.setProductType(BillingClient.ProductType.SUBS)
125+
.build()
126+
val result = billingClient.queryPurchasesAsync(params)
127+
return result.purchasesList
128+
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
129+
.map { it.toNativePurchase() }
130+
}
131+
132+
/** Play's receipt re-link IS `queryPurchasesAsync` — there is no separate sync endpoint (D7). */
133+
override suspend fun sync() {
134+
queryPurchases()
135+
}
136+
137+
override suspend fun restore(): List<NativePurchase> = queryPurchases()
138+
139+
override suspend fun manageSubscription(productId: String?) {
140+
val url = if (productId != null) {
141+
"https://play.google.com/store/account/subscriptions" +
142+
"?sku=$productId&package=${appContext.packageName}"
143+
} else {
144+
"https://play.google.com/store/account/subscriptions"
145+
}
146+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
147+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
148+
}
149+
appContext.startActivity(intent)
150+
}
151+
152+
private suspend fun queryProductDetails(productId: String): ProductDetails? {
153+
val params = QueryProductDetailsParams.newBuilder()
154+
.setProductList(
155+
listOf(
156+
QueryProductDetailsParams.Product.newBuilder()
157+
.setProductId(productId)
158+
.setProductType(BillingClient.ProductType.SUBS)
159+
.build(),
160+
),
161+
)
162+
.build()
163+
val result = billingClient.queryProductDetails(params)
164+
return result.productDetailsList?.firstOrNull()
165+
}
166+
167+
private suspend fun acknowledgeIfNeeded(purchase: Purchase) {
168+
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED || purchase.isAcknowledged) return
169+
val ack = billingClient.acknowledgePurchase(
170+
AcknowledgePurchaseParams.newBuilder()
171+
.setPurchaseToken(purchase.purchaseToken)
172+
.build(),
173+
)
174+
if (ack.responseCode != BillingResponseCode.OK) {
175+
Logger.w("PlayBillingNativeClient") {
176+
"acknowledgePurchase failed (${ack.responseCode}): ${ack.debugMessage}"
177+
}
178+
}
179+
}
180+
181+
/** Idempotent, serialized connect — no-op when the client is already ready. */
182+
private suspend fun ensureConnected(): BillingResult = connectMutex.withLock {
183+
if (billingClient.isReady) return@withLock okResult()
184+
suspendCancellableCoroutine { cont ->
185+
billingClient.startConnection(
186+
object : BillingClientStateListener {
187+
override fun onBillingSetupFinished(billingResult: BillingResult) {
188+
if (cont.isActive) cont.resume(billingResult)
189+
}
190+
191+
// Next ensureConnected() reconnects lazily via the isReady guard above.
192+
override fun onBillingServiceDisconnected() = Unit
193+
},
194+
)
195+
}
196+
}
197+
198+
private fun okResult(): BillingResult = BillingResult.newBuilder().setResponseCode(BillingResponseCode.OK).build()
199+
200+
private fun Purchase.toNativePurchase(): NativePurchase = NativePurchase(
201+
productId = products.firstOrNull().orEmpty(),
202+
purchaseToken = purchaseToken,
203+
originalTransactionId = orderId,
204+
purchaseTimeMillis = purchaseTime,
205+
isAutoRenewing = isAutoRenewing,
206+
packageName = packageName,
207+
)
208+
209+
private data class PurchasesUpdate(val billingResult: BillingResult, val purchases: List<Purchase>)
210+
}

0 commit comments

Comments
 (0)