DEV Community

Cover image for Inside Sonna AI: Building a Spec-Driven Multi-Modal AI Studio with NestJS, Turso DB, and Native Android (Kotlin)
Mad-jr
Mad-jr

Posted on

Inside Sonna AI: Building a Spec-Driven Multi-Modal AI Studio with NestJS, Turso DB, and Native Android (Kotlin)

Hey developers! đź‘‹

We are excited to share Sonna AI, a unified multi-modal AI Creative Studio built for creators and developers. In this post, we'll cover how we built and refactored Sonna AI using Kiro's Agentic IDE—orchestrating spec-driven changes across our Next.js web client, NestJS backend, and native Android app.

Web Dashboard: sonnalabs.app

Android App: Google Play Store

Sonna AI Dashboard and Mobile App Preview


🛠️ The Tech Stack & Production Status

Our infrastructure is fully live in production (deployed via custom PM2 configurations and automated shell deployment scripts) on a dedicated VPS stack:

  • Backend: NestJS (Node.js framework running on PM2 as sonnalabs-backend)
  • Database: Turso DB (Distributed SQLite with 19 active tables)
  • Cache & Rate Limiting: Redis
  • Storage: Cloudflare R2 (S3-compatible bucket storage for media output)
  • Payments: Midtrans (midtrans.com) for Web and Google Play Billing for Android
  • Web Client: Next.js (React framework running on localhost/production)
  • Mobile Client: Native Android built in Kotlin (integrated with Google Play Billing)

đź“‹ Architectural Deep Dive: Resilient Multi-Platform Billing (SSOT)

Handling subscriptions and credit allocations across different payment platforms (like Midtrans for our web client and Google Play Billing for Android) is notorious for logic drift and fraud. We built a strict Single Source of Truth (SSOT) billing engine using atomic transactions and proration strategies to unify these payment gateways.

1. Google Play RTDN Safety Net

To prevent wrongful account downgrades due to delayed Google Play RTDN (Real-Time Developer Notifications) webhooks, we used Kiro to design a pre-downgrade validator. Before executing a lazy downgrade on an expired subscription, our backend calls verifyAndDowngradeUserIfExpired(userId) in backend/sonna/src/services/billing/api-utils.ts.

export async function verifyAndDowngradeUserIfExpired(userId: string): Promise<boolean> {
  // 1. Fetch latest completed purchase token
  const tokenRow = await db.execute({
    sql: "SELECT purchase_token FROM purchases WHERE user_id = ? AND purchase_kind = 'subscription' AND status = 'completed' AND platform IN ('android', 'google_play') ORDER BY verified_at DESC LIMIT 1",
    args: [userId],
  });
  const token = tokenRow.rows[0]?.purchase_token ? String(tokenRow.rows[0].purchase_token) : null;

  // 2. Perform live check against Google Play API if token exists
  if (token) {
    const verify = await verifyGooglePlaySubscriptionV2(SUBSCRIPTION_PRODUCT_ID, token);
    if (verify.valid && verify.expiryMs && verify.expiryMs > Date.now()) {
       // Extend subscription expiry and prevent downgrade
       return false; 
    }
  }
  // 3. Fallback to downgrade if verification fails
  await downgradeUserToFree(userId);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

If Google Play confirms the automatic renewal is active, the downgrade is canceled, and database expiry timestamps are extended.

2. Transaction Safety: Upfront Wallet Deductions & Ledger Auditing

To prevent race conditions where two concurrent requests on different PM2 instances could double-spend credits, we implemented an atomic transaction read-modify-write pattern in deductCredits():

export async function deductCredits(userId: string, amount: number, meta?: { jobId?: string; feature?: string }) {
  const tx = await db.transaction("write");
  try {
    const read = await tx.execute({
      sql: "SELECT subscription_credits, payg_credits, credits, plan_type FROM users WHERE id = ?",
      args: [userId],
    });
    if (read.rows.length === 0) {
      await tx.rollback();
      return { success: false, error: "User not found" };
    }

    const r = read.rows[0];
    const sub = Number(r.subscription_credits || 0);
    const payg = Number(r.payg_credits || 0);
    const free = Number(r.credits || 0);

    // Calculate split waterfall: Subscription Credits -> PAYG -> Free Credits
    const breakdown = splitWaterfall(amount, sub, payg, free);
    const newSub = sub - breakdown.fromSubscription;
    const newPayg = payg - breakdown.fromPayg;
    const newFree = free - breakdown.fromFree;

    // Perform atomic deduction
    await tx.execute({
      sql: "UPDATE users SET subscription_credits = ?, payg_credits = ?, credits = ? WHERE id = ?",
      args: [newSub, newPayg, newFree, userId],
    });

    // Log to audit credit_ledger in the same transaction
    await tx.execute({
      sql: "INSERT INTO credit_ledger (id, user_id, kind, job_id, feature, amount, from_subscription, from_payg, from_free) VALUES (?, ?, 'deduct', ?, ?, ?, ?, ?, ?)",
      args: [randomUUID(), userId, meta?.jobId, meta?.feature, amount, breakdown.fromSubscription, breakdown.fromPayg, breakdown.fromFree],
    });
    await tx.commit();
  } catch (error) {
    await tx.rollback();
  }
}
Enter fullscreen mode Exit fullscreen mode

If the downstream generation fails (e.g., the model provider returns a transient error), refundCredits() parses the exact ledger breakdown and restores the credits to their original buckets with zero-ambiguity.

3. Verification Security & Replay Prevention

When refactoring billing.controller.ts, we prompted Kiro to prioritize security. Kiro helped us implement a verifyPurchase() endpoint that enforces strict checks:

  • Replay Prevention: Purchase tokens are hashed and checked against the database (iap_${purchaseToken} or restore_${purchaseToken}) to block duplicate claims.
  • Cross-Account Token Guard: Prevent users from claiming tokens purchased by another account (tokenOwner.user_id !== userId).
  • Accumulation & Zeroing Policy: A paid subscription zeroes the free credits bucket to prevent stacking free and paid allocations. Upgrades dynamically add credits (subscription_credits = subscription_credits + new_allocation) without wiping old balances.

4. Client-Side Android Billing Flows

Writing the Android client logic manually can be error-prone, but Kiro mapped the Google Play replacement modes flawlessly. In BillingViewModel.kt, we handle Google Play subscriptions by calculating the difference between plans to assign the correct replacement mode:

val replacementMode = if (targetRank > currentRank) {
    // Upgrade (Pro -> Max)
    BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE
} else {
    // Downgrade (Max -> Pro)
    BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION
}
Enter fullscreen mode Exit fullscreen mode

This ensures Google Play charges the full price immediately for upgrades (carrying over remaining time), while downgrades only apply at the next renewal, preserving the user's current tier for the active billing cycle.


⚡ The Platform Configuration Layer (Zero-Redeploy Architecture)

To minimize downtime, we asked Kiro to help architect a database-driven provider orchestration layer. This allows us to scale rate limits, switch providers, and monitor errors without redeploying code:

  • Visual Generation Routing: All image and video generations route through a multi-key API key pool (API_KEY_0 and API_KEY_1) targeting our serverless model generation endpoints (FLUX, LTX-Video, and WanVideo).
  • DB-Driven Settings: Rate limits and feature flags are loaded from Turso DB (provider_config and feature_flags tables) and cached in-memory with a 5-minute TTL. Toggling an engine or updating a rate window (e.g., changing a provider's limit of 18/10s to 50/10s) requires a single SQL UPDATE statement.
  • Universal Logging & Slack/Telegram Alerts: Errors across our generation engines, ElevenLabs, Google TTS, and Gemini TTS are logged in provider_error_logs. If errors cross a threshold (>10 errors/hour per provider), our backend automatically dispatches a Telegram alert. We can export full error reports as CSVs using /api/admin/provider-error-report.csv to forward logs directly to support tickets.

🤖 Kiro-Driven Development: Our Agentic IDE Experience

As a developer showcase entry, the real hero of Sonna AI's cross-platform journey is Kiro's Agentic IDE. Rather than using Kiro as a simple autocomplete helper, we integrated its plan-first methodology directly into our development lifecycle to coordinate changes between our Next.js web application, NestJS backend, and native Android app.

Here is a look at what it actually felt like to build and refactor an enterprise-scale SaaS with Kiro:

1. Spec-Driven Planning & EARS Notation

Instead of jumping straight into coding and ending up with out-of-sync API models, we initiated our development with Kiro's Spec-driven workflow. Before modifying the billing controllers, we created structured specifications in the /spec folder:

  • requirements.md: Mapped business rules (e.g., distinguishing one-time prepaid web passes from recurring Android subscriptions).
  • design.md: Defined exact API schemas and DB migration templates using formal EARS (Easy Approach to Requirements Syntax) notation.

Kiro processed these specs, generated a detailed roadmap in tasks.md, and proceeded to coordinate file creations across the codebase—guaranteeing that the Kotlin clients and NestJS server endpoints stayed completely aligned.

2. Guarding Architecture Boundaries with Steering Rules

In a large multi-platform workspace, it is easy for AI assistants to mix up layers—like importing Node modules into Android code or writing raw queries inside HTTP controllers. To enforce strict system boundaries, we defined custom steering parameters in .kiro/steering/:

  • We instructed Kiro to isolate native Android business logic inside dedicated ViewModels and cleanly split DB access using our Repository pattern.
  • We standardized on in-memory caching with a 5-minute TTL for configurations.

Because Kiro respects steering configurations, the generated code felt like it was written by our lead engineer, maintaining zero structural drift.

3. Solving the Double-Spend Race Condition: Iterative Refactoring

One of Kiro's most impressive feats was helping us refactor the critical credit deduction race condition. Originally, our NestJS backend did a standard SELECT then UPDATE sequence. Under high concurrency across multiple PM2 clustering instances, two requests could check balances simultaneously, bypassing limits.

We prompted Kiro to refactor the logic in api-utils.ts to be fully thread-safe. Kiro analyzed the Turso DB SQLite constraints and immediately generated:

  1. A database-level write transaction to lock the user row.
  2. A pure waterfall calculator (splitWaterfall()) to dynamically subtract from subscription_credits first, then payg_credits, and finally credits.
  3. An append-only credit_ledger entry executed inside the exact same database transaction for auditable tracebacks.

Kiro generated the transaction block, verified rollback exceptions, and automatically added concurrency verification suites to our Vitest test suite to ensure the fix was robust.

4. Orchestrating Parallel Agents

For the billing launch, we needed to build the Google Play RTDN webhook handler (play-rtdn.service.ts) while simultaneously updating the Android client's payment state and generating integration tests.

Kiro orchestrated this by spawning parallel sub-agents:

  • Agent A constructed the NestJS RTDN verification service.
  • Agent B generated the Kotlin client endpoints and mapped Google Play's proration/replacement billing parameters.
  • Agent C wrote our Vitest billing integration tests.

Having parallel agent orchestration eliminated manual API contract negotiation, cutting our development cycle in half.


📱 Android Kotlin Client UI Showcase

Here is a quick look at our native mobile client interface built in Kotlin:

Home & New Generation

Home Screen

New Generation

Explore Music & Media & Library

Explore Music

Media Section

Library Screen

Creator Space

Creator Space


🚀 Live Demo & Feedback

Sonna AI is now live in production!

đź’ˇ Access Policy Note: Anyone can download the app and sign up. The free tier gives you access to Google TTS. Generation features for Music, Images, Videos, as well as advanced TTS engines (ElevenLabs and Gemini TTS) require subscription credits or PAYG credits.

We would love to hear your thoughts on our DB-driven platform layer design, our Google Play billing proration setup, or how you utilize agentic steering files in your own workflows. Any feedback on our Web or Android client UI is highly appreciated!

Happy coding!

Top comments (0)