<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: arm vall</title>
    <description>The latest articles on DEV Community by arm vall (@arm_vall_15d8a4237b9d8919).</description>
    <link>https://dev.to/arm_vall_15d8a4237b9d8919</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4243499%2F430a399a-0556-4897-9ccb-251fb3e8a13d.png</url>
      <title>DEV Community: arm vall</title>
      <link>https://dev.to/arm_vall_15d8a4237b9d8919</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arm_vall_15d8a4237b9d8919"/>
    <language>en</language>
    <item>
      <title>We Shipped a Bilingual Next.js PWA to Play Closed Testing. Production Access Was Rejected. Here’s What Google Asked For.</title>
      <dc:creator>arm vall</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:16:27 +0000</pubDate>
      <link>https://dev.to/arm_vall_15d8a4237b9d8919/we-shipped-a-bilingual-nextjs-pwa-to-play-closed-testing-production-access-was-rejected-heres-2f25</link>
      <guid>https://dev.to/arm_vall_15d8a4237b9d8919/we-shipped-a-bilingual-nextjs-pwa-to-play-closed-testing-production-access-was-rejected-heres-2f25</guid>
      <description>&lt;p&gt;I wanted one codebase that a person in Miami and a person in Bogotá could both use in their first language, install on a phone, and keep using even before a public store listing existed. The result is a Next.js app that runs in the browser, installs as a progressive web app, and is also wrapped as an Android Trusted Web Activity for Google Play closed testing.&lt;/p&gt;

&lt;p&gt;The product is &lt;a href="https://plenifa.com" rel="noopener noreferrer"&gt;Plenifa&lt;/a&gt;, an AI life coach and language tutor. I’ll mention it where the architecture and the Play review are about this app, and I’ll be precise about what is — and is not — on the Play Store.&lt;/p&gt;

&lt;h3&gt;
  
  
  English and Spanish as first-class URLs
&lt;/h3&gt;

&lt;p&gt;The app is a Next.js App Router project (we build with Node 22 and a standalone server output). The public site is bilingual in a deliberately boring way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;English is the bare URL, and also &lt;code&gt;x-default&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Spanish is the same path with &lt;code&gt;?lang=es&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The canonical tag follows the &lt;code&gt;lang&lt;/code&gt; query only. It does not follow &lt;code&gt;Accept-Language&lt;/code&gt; or a language cookie. Auto-detecting a crawler into the wrong canonical is a good way to scramble search results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coaching and the chrome of the app are English and Spanish. A language-tutor mode can practice 35 languages, including sessions of real Spanish for life in Latin America and professional English for work. Those other languages are for practice; they are not a claim that the whole interface ships in 35 locales.&lt;/p&gt;

&lt;p&gt;Signup is for adults 18 and over. The product is coaching and language practice, not therapy, and not medical, legal, financial, or mental-health advice. The UI says that the assistant can make mistakes.&lt;/p&gt;

&lt;p&gt;Because this is a PWA, someone can install it from the browser on Android, iPhone, Windows, Mac, or a Chromebook and get an icon, a standalone window, and a service worker. That path still works today, and it does not depend on a public Play listing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Play build is a TWA, and it does not sell subscriptions
&lt;/h3&gt;

&lt;p&gt;The Android package is &lt;code&gt;com.plenifa.app&lt;/code&gt;. It is a Trusted Web Activity, generated with Bubblewrap, pointing at the live site. The start URL includes &lt;code&gt;src=twa&lt;/code&gt;. The web app treats that flag (and the &lt;code&gt;android-app://&lt;/code&gt; referrer) as “we are inside the Play app,” and it hides every purchase and upgrade surface.&lt;/p&gt;

&lt;p&gt;That is not a pricing experiment. Google Play’s payments policy expects Play Billing for digital subscriptions sold inside a Play app. For the first Play version we chose the conservative reading of the anti-steering rules: do not sell, and do not point people at a web checkout, from inside the store app. People who already subscribed on the web keep the same account and entitlements. Play Billing is a later step, not something we pretended to have in v1.&lt;/p&gt;

&lt;p&gt;Digital Asset Links are served at &lt;code&gt;https://plenifa.com/.well-known/assetlinks.json&lt;/code&gt; so a verified install can open full-screen, without a URL bar. Privacy and terms had to be real pages with no placeholders before we submitted — Play will reject a policy page that still says “TODO.” The in-app listing is 18+, with no ads, and with a notice that replies are AI-generated.&lt;/p&gt;

&lt;p&gt;The current closed-test package is version 1.0.2 (versionCode 3). The public store listing for this package is still not live. If you search Play as a regular user, you will not find a public Plenifa listing to install. That is expected, not a typo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Closed testing on a personal developer account
&lt;/h3&gt;

&lt;p&gt;A new personal Play developer account cannot go straight to production. The rule we planned around is: at least 12 testers, opted in, who keep the app installed for 14 consecutive days, then an application for production access. Organization accounts with a D-U-N-S number skip that requirement; this account is personal, so we did not.&lt;/p&gt;

&lt;p&gt;Our own plan, written before we submitted, already said the quiet part: aim for more than 12 people in case some drop out; ask them to use chat, voice, a photo, and the English/Spanish switch a few times a week; collect feedback through the in-app button; and ship at least one or two updates during the test, because Google looks for active development. Opting in is not the same as testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the 9 November rejection actually said
&lt;/h3&gt;

&lt;p&gt;On 9 November 2026, Google Play Support wrote to the developer address with the subject “More testing required to access Google Play production.”&lt;/p&gt;

&lt;p&gt;They had reviewed the application and determined that the app needs more testing before production access. The possible reasons they listed were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Testers were not engaged with the app during the closed test.&lt;/li&gt;
&lt;li&gt;Testing best practices may not have been followed, which may include gathering and acting on user feedback through updates to the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before applying again, they asked us to test the app in closed testing for an &lt;strong&gt;additional 14 days with real testers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the whole lesson, and it is more useful than a vague “Play is strict.” Headcount in a Google Group is not engagement. A tester who opts in and never opens the app does not help you, and it does not look like a test to Play. Shipping a versionCode bump is not the same as changing the app because someone reported a problem. We had already learned, in ordinary QA, that the Play/TWA shell has sharp edges — microphone permission in a full-screen TWA is not the same as in Chrome, and the in-app steps have to match the surface the user is actually looking at. Those are the kinds of things only show up if testers open the app and talk back.&lt;/p&gt;

&lt;p&gt;I will not dress this up with a tester count, a rating, or a claim that the public listing is “coming this week.” The honest status is: the app remains in closed testing; production access was not granted on that application; we need another 14 days of closed testing with people who actually use the build.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we’re asking of testers now
&lt;/h3&gt;

&lt;p&gt;If you want to try a bilingual AI coach and tutor, you do not have to wait for Play. The website and the PWA are the same product.&lt;/p&gt;

&lt;p&gt;If you have an Android phone and you’re willing to help with the store path: join the &lt;a href="https://groups.google.com/g/plenifa-testers" rel="noopener noreferrer"&gt;plenifa-testers Google Group&lt;/a&gt;, then opt in at &lt;a href="https://play.google.com/apps/testing/com.plenifa.app" rel="noopener noreferrer"&gt;https://play.google.com/apps/testing/com.plenifa.app&lt;/a&gt;. Please install it, open it, and use it over 14 days — send a message, try voice, switch English and Spanish, and tell us what broke through the in-app feedback button. That is the work Google asked for, and it is also how the app gets better.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>android</category>
      <category>pwa</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Keep a 50% Margin on an AI Subscription: Per-User Cost Caps That Actually Bind</title>
      <dc:creator>arm vall</dc:creator>
      <pubDate>Fri, 13 Nov 2026 19:15:40 +0000</pubDate>
      <link>https://dev.to/arm_vall_15d8a4237b9d8919/keep-a-50-margin-on-an-ai-subscription-per-user-cost-caps-that-actually-bind-50ok</link>
      <guid>https://dev.to/arm_vall_15d8a4237b9d8919/keep-a-50-margin-on-an-ai-subscription-per-user-cost-caps-that-actually-bind-50ok</guid>
      <description>&lt;p&gt;If you sell a monthly plan that talks to large language models, the user who loves the product is the one who can wipe out your margin. A “fair use” sentence in the terms does not stop a model bill. You need a ceiling in code, denominated in the same dollars the provider charges you, and you need that ceiling to be a hard stop — not a polite downgrade that keeps spending.&lt;/p&gt;

&lt;p&gt;I built this for &lt;a href="https://plenifa.com" rel="noopener noreferrer"&gt;Plenifa&lt;/a&gt;, a bilingual AI life coach and language tutor. This is a write-up of the metering design in the running app, not a pitch for the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two numbers per request, not one
&lt;/h3&gt;

&lt;p&gt;Every metered action writes a row with two dollar amounts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real cost&lt;/strong&gt; — what the model provider billed, in USD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Charged amount&lt;/strong&gt; — real cost multiplied by a tier multiplier, which is what draws down the user’s plan allowance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cheap “economy” models are charged at 1×. Mid-range “standard” models are 1.2×, “premium” 1.5×, and “frontier” 2×. The multiplier is a product choice: a user who picks a costly model burns the visible allowance faster, while the hard cap (described below) still counts &lt;em&gt;real&lt;/em&gt; dollars. Fallback replies are logged so they can be counted, but they do not consume the allowance the way a normal plan message does. Optional top-up credit is a separate balance the user has already paid for, and it is not counted toward the monthly spend cap.&lt;/p&gt;

&lt;p&gt;If you only track tokens, or only track “messages,” a vision request or a long spoken reply will not look like what it costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design the cap from fees, not from a round number
&lt;/h3&gt;

&lt;p&gt;The rule in our config is simple enough to test, and it is unit-tested.&lt;/p&gt;

&lt;p&gt;Take the plan price. Subtract a &lt;strong&gt;worst-case payment fee&lt;/strong&gt; — the worst of PayPal (3.49% + $0.49), a conservative dLocal Go card fee (3.99% + $0.30, plus tax on the fee), and a Stripe international case (2.9% + 0.7% Billing + 1.5% international card + 1% FX + $0.30). Then subtract &lt;strong&gt;50% of the list price&lt;/strong&gt;. Then divide by 1.055 to leave room for the model router’s 5.5% credit-purchase fee. Round down to the cent. That number is &lt;code&gt;monthlySpendCapUsd&lt;/code&gt;: a hard ceiling on real provider USD for the billing period.&lt;/p&gt;

&lt;p&gt;The user’s visible &lt;strong&gt;allowance&lt;/strong&gt; (&lt;code&gt;monthlyBudgetUsd&lt;/code&gt;) is 90% of that hard cap. Weekly and daily windows are scaled in proportion. When the allowance is gone but the hard cap is not, we are in a soft-cap band: plain text can continue on a cheap economy fallback; photos, files, image generation, and HD voice stop at 100% of the allowance and offer a top-up or upgrade.&lt;/p&gt;

&lt;p&gt;Paid plans and the hard caps currently look like this (USD, monthly):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Allowance&lt;/th&gt;
&lt;th&gt;Hard cap (real $)&lt;/th&gt;
&lt;th&gt;Daily hard stop&lt;/th&gt;
&lt;th&gt;Weekly window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;6.99&lt;/td&gt;
&lt;td&gt;2.34&lt;/td&gt;
&lt;td&gt;2.61&lt;/td&gt;
&lt;td&gt;0.39&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plus&lt;/td&gt;
&lt;td&gt;19.99&lt;/td&gt;
&lt;td&gt;7.22&lt;/td&gt;
&lt;td&gt;8.03&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max&lt;/td&gt;
&lt;td&gt;39.99&lt;/td&gt;
&lt;td&gt;14.71&lt;/td&gt;
&lt;td&gt;16.35&lt;/td&gt;
&lt;td&gt;2.55&lt;/td&gt;
&lt;td&gt;4.39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ultra&lt;/td&gt;
&lt;td&gt;69.99&lt;/td&gt;
&lt;td&gt;25.94&lt;/td&gt;
&lt;td&gt;28.83&lt;/td&gt;
&lt;td&gt;4.53&lt;/td&gt;
&lt;td&gt;7.77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;37.18&lt;/td&gt;
&lt;td&gt;41.32&lt;/td&gt;
&lt;td&gt;6.49&lt;/td&gt;
&lt;td&gt;11.15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every feature is on every plan, including Free. Plans differ by how much you can use, not by locking tools behind a paywall. Free also has a 30-message daily cap as an abuse guard.&lt;/p&gt;

&lt;p&gt;Annual billing charges 10 months (two months free). Every dollar limit on an annual plan is scaled by 10/12, because the payment fee is taken once on the annual amount. The same 50% floor still holds.&lt;/p&gt;

&lt;p&gt;Top-up packs follow the same idea. A $5 / $10 / $25 pack currently adds $1.73 / $3.87 / $10.11 of credit, after the same worst-case fee, the 50% hold-back, and the 5.5% credit fee, rounded down. Older in-flight checkouts keep their original credit; balances already bought are not rewritten.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enforcement order matters more than the config file
&lt;/h3&gt;

&lt;p&gt;A cap checked only at month-end is a post-mortem. The chat path checks, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Daily hard stop&lt;/strong&gt; — real dollars today, from every source, including voice. If it is reached, the API returns 429 and the user waits until midnight (the daily window resets at local midnight on the server).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free-plan message caps&lt;/strong&gt; — per account, and also per IP and device so a pile of free accounts on one phone does not share an unlimited pool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The monthly real-dollar spend cap.&lt;/strong&gt; On these plans the policy is &lt;code&gt;block&lt;/code&gt;. If the cap is hit and the user has no top-up credit, the API returns 402 with the existing top-up packs and an upgrade prompt. There is no “keep answering on a fallback model” path around that block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The weekly and monthly allowances&lt;/strong&gt; (charged dollars). If those are gone, top-up credit can still pay; otherwise media stops, and text may use a limited number of cheap fallback messages, up to a per-plan daily count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We also flag anomalies (bursts, repeated prompts, a day far above the plan’s daily average). Those flags are for review; they do not, by themselves, cut the user off. The dollar checks do.&lt;/p&gt;

&lt;h3&gt;
  
  
  The leaks we had to close
&lt;/h3&gt;

&lt;p&gt;This was not the first version of the design.&lt;/p&gt;

&lt;p&gt;An earlier paid-plan policy was &lt;code&gt;onSpendCap: "degrade"&lt;/code&gt;: after the monthly spend cap, chat kept running on a fallback model up to a daily fallback-message count. That sounds gentle. It is not a cost ceiling. A vision fallback is cheap per token but not free, and over a 31-day period the only thing that actually stopped spending was the daily hard stop. On every plan we modeled, a user who maxed that path could spend more than the subscription.&lt;/p&gt;

&lt;p&gt;HD voice had a second leak: it was capped in &lt;strong&gt;characters&lt;/strong&gt;, not dollars. A “500,000 characters a month” cap is a very different bill if one voice is $15 per million characters and another is $100. Listen usage is now counted in &lt;strong&gt;cost-weighted characters&lt;/strong&gt; against a reference voice, so a more expensive voice burns the allowance faster. Max’s monthly HD allowance was cut from 500k to 250k of those weighted characters (Free is 5,000). Cache hits for the same text, voice, and speed cost $0 and do not count.&lt;/p&gt;

&lt;p&gt;We also dropped a rolling five-hour window. Daily, weekly, monthly, and the hard cap were already doing the work; another window made the meter harder to explain.&lt;/p&gt;

&lt;p&gt;None of that is visible to a typical light user. It only shows up when someone tries to run the allowance all the way to the wall.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this does not pretend to do
&lt;/h3&gt;

&lt;p&gt;This design guarantees a &lt;strong&gt;floor&lt;/strong&gt; on margin if someone uses every leftover dollar the caps allow, after a pessimistic fee. It does not tell you what a typical user will cost. We did not use production medians to pick these numbers; the 50% figure is a worst-case constraint, not an observed average. A pre-flight estimate can undershoot the last message in a window, so that request can go slightly past it. The hard cap still sits below half the list price after fees.&lt;/p&gt;

&lt;p&gt;Hosting, support time, and everything that is not a per-request model bill sit outside this meter. The meter is for the part that scales linearly with a power user.&lt;/p&gt;

&lt;p&gt;If you are wiring something similar, the habits that mattered were: store real cost and charged cost on every event; put voice and images on the same ledger as chat; make “over cap” a 402, not a quieter model; and put the formula in config so a price change cannot drift from the fee math without a failing test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Closed testing
&lt;/h3&gt;

&lt;p&gt;Plenifa’s Android app (&lt;code&gt;com.plenifa.app&lt;/code&gt;) is in Google Play closed testing. It is not a public Play Store listing. If you have an Android phone and want to help: join the &lt;a href="https://groups.google.com/g/plenifa-testers" rel="noopener noreferrer"&gt;plenifa-testers Google Group&lt;/a&gt;, then opt in at &lt;a href="https://play.google.com/apps/testing/com.plenifa.app" rel="noopener noreferrer"&gt;https://play.google.com/apps/testing/com.plenifa.app&lt;/a&gt;. Please install the app, open it, and actually use it (chat, voice, English/Spanish) over 14 days. Feedback through the in-app button is welcome. You can also use the product in the browser at &lt;a href="https://plenifa.com" rel="noopener noreferrer"&gt;plenifa.com&lt;/a&gt; and install it as a PWA.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
