<?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: KunStudio</title>
    <description>The latest articles on DEV Community by KunStudio (@kunstudio).</description>
    <link>https://dev.to/kunstudio</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%2F3915093%2F11aedda3-8712-45a5-a1f7-05920ccb0819.jpg</url>
      <title>DEV Community: KunStudio</title>
      <link>https://dev.to/kunstudio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunstudio"/>
    <language>en</language>
    <item>
      <title>Building for Korean users: 5 quirks Western devs always get wrong</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 10 Aug 2026 00:20:59 +0000</pubDate>
      <link>https://dev.to/kunstudio/building-for-korean-users-5-quirks-western-devs-always-get-wrong-1la6</link>
      <guid>https://dev.to/kunstudio/building-for-korean-users-5-quirks-western-devs-always-get-wrong-1la6</guid>
      <description>&lt;h2&gt;
  
  
  Building for Korean Users: 5 Quirks Western Devs Always Get Wrong
&lt;/h2&gt;

&lt;p&gt;I've watched Western engineers ship products to Korea and watch them fail spectacularly. Not because the products were bad—often they were technically solid. But they built for themselves, not for Korean users. After three years running a product that serves tens of thousands of Korean users, I've documented the patterns that actually matter.&lt;/p&gt;

&lt;p&gt;Korean users operate with different expectations shaped by the country's tech infrastructure, regulatory environment, and cultural norms. These aren't edge cases you can ignore. They're core requirements that determine whether your product gains traction or gets relegated to the "foreign app" category that nobody uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You're Ignoring Local Payment Methods Entirely
&lt;/h2&gt;

&lt;p&gt;This is the killer mistake. You support Stripe, maybe PayPal. Korean users look at your payment options and immediately leave. &lt;/p&gt;

&lt;p&gt;Korea's payment ecosystem is fundamentally different. The primary methods are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credit cards through specific Korean acquirers&lt;/strong&gt; (most Korean credit cards won't work through international gateways reliably)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naver Pay and Kakao Pay&lt;/strong&gt; (these two control roughly 70% of mobile payments in Korea)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bank transfer (계좌이체)&lt;/strong&gt; - still used heavily for B2B and online purchases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile carrier billing&lt;/strong&gt; (SKT, KT, LG U+) - particularly for younger users and in-app purchases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can't just add Naver Pay as an afterthought. These services have specific integration requirements, settlement delays (typically 3-5 days), and their own user authentication flows.&lt;/p&gt;

&lt;p&gt;I integrated Kakao Pay into my product and watched conversion from Korean users jump 340% in the first month. When I added Naver Pay two months later, another 180% increase. These aren't niche options—they're table stakes.&lt;/p&gt;

&lt;p&gt;The cost is non-trivial. Gateway fees run 2.9-3.5% for Kakao/Naver Pay, compared to 2.2% for Stripe. But losing 90% of your Korean market because payment isn't available costs far more.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your Phone Number Validation Is Broken
&lt;/h2&gt;

&lt;p&gt;Western developers build phone validation assuming E.164 format and country code prefix. Then Korean users enter their phone numbers without the +82 prefix (they expect just "010-1234-5678"), and the system rejects it.&lt;/p&gt;

&lt;p&gt;More subtle: you're probably using a library like &lt;code&gt;libphonenumber&lt;/code&gt; which is good, but you're not handling Korean-specific validation rules. Korean mobile numbers have specific patterns—010, 011, 016, 017, 018, 019 prefixes for mobile, 02-XXXX-XXXX for Seoul landlines. If you're not validating these patterns correctly, you'll reject legitimate numbers or accept invalid ones.&lt;/p&gt;

&lt;p&gt;I've seen products fail SMS verification on first attempt for 35% of Korean users because the validation was too strict about formatting. The solution: localize your number input component with proper placeholder formatting (010-####-####), validate against Korean-specific patterns, and accept flexible input formats (with or without hyphens, with or without country code).&lt;/p&gt;

&lt;p&gt;Test this thoroughly. Your US test phone numbers won't catch these issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. You're Not Handling the Real Name Registration Requirement
&lt;/h2&gt;

&lt;p&gt;Korea has strict real-name verification requirements for financial transactions, online purchases, and certain services. This isn't optional—it's regulatory.&lt;/p&gt;

&lt;p&gt;If your product handles money, content moderation, or identity-related features, you need &lt;strong&gt;I-PIN (Internet Personal Identification Number) verification&lt;/strong&gt; or &lt;strong&gt;NICE/KCP authentication service integration&lt;/strong&gt;. These are the certified Korean identity verification systems.&lt;/p&gt;

&lt;p&gt;Many Western products ignore this and offer anonymous sign-up. Korean financial regulators will shut you down. It's not a gray area—it's black and white.&lt;/p&gt;

&lt;p&gt;The implementation is annoying. You need to integrate with NICE (pronounced "nee-say") or similar services which require:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Korean registered company&lt;/li&gt;
&lt;li&gt;Specific certification levels&lt;/li&gt;
&lt;li&gt;Integration through their XML-RPC APIs (yeah, it's 2024 and they're still using XML-RPC in some places)&lt;/li&gt;
&lt;li&gt;Handling of encrypted personal identification data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Costs run 20-100 KRW per verification (roughly $0.015-$0.075 USD). The infrastructure is robust but dated. Plan 2-3 weeks for integration if you've never done it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. You Forgot About ISMS Compliance and Data Localization
&lt;/h2&gt;

&lt;p&gt;Korea's &lt;strong&gt;Information Security Management System (ISMS)&lt;/strong&gt; certification isn't a suggestion if you're handling user data. Major companies, government contracts, and even mid-market enterprises won't touch products without it.&lt;/p&gt;

&lt;p&gt;More importantly: &lt;strong&gt;Korean personal data must be stored on Korean servers&lt;/strong&gt;. Not "backed up" to Korean servers—actually stored there. You can't run everything on AWS us-east-1 with a compliance checkbox. Article 17 of the Personal Information Protection Act is explicit.&lt;/p&gt;

&lt;p&gt;What this means practically: if you're processing credit card payments, real names, phone numbers, or identity information, you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers physically located in Korea (AWS Seoul region exists, but also consider Naver Cloud, KT Cloud, or NHN Cloud)&lt;/li&gt;
&lt;li&gt;Encryption at rest and in transit (TLS 1.2 minimum, though 1.3 is increasingly required)&lt;/li&gt;
&lt;li&gt;Audit logs that survive at least 3 years&lt;/li&gt;
&lt;li&gt;A written data processing agreement in Korean&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've seen products get popular, then suddenly need to rebuild their entire infrastructure when a customer asked for ISMS certification. The Wannacry and Ashley Madison incidents made Korean regulations tighter, not looser.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. You're Shipping Without Korean Language Support and It's Worse Than You Think
&lt;/h2&gt;

&lt;p&gt;This is not just translation. This is localization.&lt;/p&gt;

&lt;p&gt;Your English UI with Korean translation will work, yes. But "localization" for Korean needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proper handling of 받침 (final consonants)&lt;/strong&gt; in UI components—Korean particles change based on whether words end with consonants&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date and time formats&lt;/strong&gt; that feel natural (Koreans use both Western calendar and lunar calendar for certain contexts)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency formatting&lt;/strong&gt;: 1,000,000 won displays as "₩1,000,000" not "100 cents"—the visual weight matters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error messages that don't sound robotic&lt;/strong&gt;—Korean translation tools often produce stilted, unnatural phrasing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More critically: &lt;strong&gt;right-to-left considerations don't apply, but vertical text considerations do&lt;/strong&gt; in certain contexts (though less common in software). More important is understanding that Korean reads fast—dense information that would require explanation in English can be compressed in Korean because of the writing system's efficiency.&lt;/p&gt;

&lt;p&gt;I launched with Google Translate-level Korean once. Users complained the interface felt "foreign" and "cheap." After hiring a native Korean writer to rewrite every string naturally, not literally, retention improved 22%. It wasn't about correctness—native speakers understood the bad translation fine. It was about feeling local.&lt;/p&gt;

&lt;p&gt;Budget proper translation at $0.12-0.20 per word minimum for quality. That's roughly $3,000-8,000 for a typical app's user-facing strings. Cheap translation is expensive in terms of user perception.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Compounding Effect
&lt;/h2&gt;

&lt;p&gt;These aren't five independent problems—they compound. A user who can't pay easily, has phone number validation issues, encounters an unlocalized interface, and worries about data privacy will simply use a Korean alternative instead. You don't get five chances to fail. You get zero.&lt;/p&gt;

&lt;p&gt;Korean users are pragmatic. If your product doesn't work smoothly for them, they'll use Naver, Kakao, or a local competitor. The Korean tech market is competitive, mature, and unforgiving of shortcuts.&lt;/p&gt;

&lt;p&gt;I built Saju (our product serving Korean users with local features) specifically because I noticed Western tools were leaving money and users on the table. The engineering required is non-trivial but manageable. The difference in traction is immediate and measurable.&lt;/p&gt;

&lt;p&gt;If you're serious about Korean users—not just "nice to have someday" but actual revenue and growth—start here. Localize payment methods, validate phone numbers correctly, handle the regulatory requirements, store data locally, and invest in proper Korean localization. The 50+ million internet users in Korea aren't a niche market. They're where you should be building.&lt;/p&gt;

&lt;p&gt;If you're building a product for Korean users or planning to expand there, I'm documenting patterns and building tools specifically for this market at &lt;a href="https://sajuapp.app/?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=longform_weekly" rel="noopener noreferrer"&gt;Saju&lt;/a&gt;. We're solving these exact problems so you don't have to rebuild them yourself.&lt;/p&gt;

</description>
      <category>korea</category>
      <category>fintech</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>é ©å å å å å… ç åº è ç ©æ »ç ¥ â ä å æ å®¢ç é ç§ ç</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 03 Aug 2026 00:30:44 +0000</pubDate>
      <link>https://dev.to/kunstudio/eca12aaaacaoeccaecy-a-aa12aearcce-cssc--45bn</link>
      <guid>https://dev.to/kunstudio/eca12aaaacaoeccaecy-a-aa12aearcce-cssc--45bn</guid>
      <description>&lt;h1&gt;
  
  
  éŸ©å›½åŒ–å¦†å“å…ç¨Žåº—è´­ç‰©æ”»ç•¥ â€” ä¸­å›½æ¸¸å®¢çœé’±ç§˜ç±
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;éŸ©å›½åŒ–å¦†å“åœ¨å…ç¨Žåº—çœŸçš„ä¾¿å®œ 30%~50%?æœ¬æŒ‡å—ä¸ºä¸­å›½æ¸¸å®¢è¯¦è§£æœºåœºå…ç¨Žã€å¸‚å†…å…ç¨Žã€å“ç‰Œä¸“æŸœçš„çœŸå®žä»·å·®,ä»¥åŠå“ªäº›å“ç‰Œè¯¥åœ¨å“ªé‡Œä¹°ã€‚&lt;/em&gt;&lt;/p&gt;

</description>
      <category>韩国化妆品</category>
      <category>免税店</category>
      <category>中国游客</category>
      <category>购物攻略</category>
    </item>
    <item>
      <title>Korean mobile-first architecture: lessons for SaaS founders</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 03 Aug 2026 00:00:35 +0000</pubDate>
      <link>https://dev.to/kunstudio/korean-mobile-first-architecture-lessons-for-saas-founders-55m0</link>
      <guid>https://dev.to/kunstudio/korean-mobile-first-architecture-lessons-for-saas-founders-55m0</guid>
      <description>&lt;h2&gt;
  
  
  Why Korea's Mobile-First Mindset Matters for Your SaaS
&lt;/h2&gt;

&lt;p&gt;Three years ago, I was building a scheduling tool in Seoul while most of my peers in San Francisco were still designing desktop-first UIs. The irony wasn't lost on me—Korea had 96.5% smartphone penetration by 2021, yet we kept shipping products that looked like they were optimized for 2010. I watched Kakao, Naver, and dozens of smaller Korean startups demolish foreign competitors not because of better engineering, but because they understood something fundamental: mobile &lt;em&gt;is&lt;/em&gt; your primary product interface.&lt;/p&gt;

&lt;p&gt;This isn't a story about mobile optimization as a checkbox feature. It's about architectural decisions that change how you think about data flow, state management, and user experience. Korean founders didn't accidentally build mobile-first; they had no choice. There's no comfortable desktop escape hatch here. And that constraint created better products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile-First Architecture, Not Mobile Optimization
&lt;/h2&gt;

&lt;p&gt;There's a critical difference. Mobile optimization means taking your desktop design and shrinking it. Mobile-first architecture means building your entire data model, API contracts, and UI framework assuming a 6-inch screen with intermittent connectivity is your primary use case.&lt;/p&gt;

&lt;p&gt;When I started building &lt;a href="https://sajuapp.app" rel="noopener noreferrer"&gt;Saju&lt;/a&gt;, I made the deliberate choice to design the data schema around mobile-first assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller request payloads&lt;/strong&gt;: Instead of fetching a user's complete data object with nested relationships, we fetch paginated, minimalist resources. A user profile response is ~2KB, not 15KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline-first capabilities&lt;/strong&gt;: We pre-cache essential data and queue mutations locally. If your 3G connection drops in the subway (and it will), the app doesn't crash—it queues actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional rendering&lt;/strong&gt;: We don't ship the same DOM across all devices. Mobile clients receive mobile-optimized templates. This seems obvious, but many SaaS platforms still load desktop-level complexity into mobile browsers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Korean platforms like Coupang use this architecture ruthlessly. Their mobile app feels snappy because the backend is literally designed to serve mobile clients efficiently, not as an afterthought.&lt;/p&gt;

&lt;p&gt;The practical result: our mobile app loads in under 2 seconds on 4G, and critical paths work offline. Desktop web is a companion experience, not the main event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Intermittent Connectivity at Scale
&lt;/h2&gt;

&lt;p&gt;Korea has excellent infrastructure, but mobile networks are still mobile networks. Subway rides, elevator transitions, and coverage gaps are real. This taught us to build systems assuming connectivity is a feature, not a guarantee.&lt;/p&gt;

&lt;p&gt;Here's what we implemented:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic updates with reconciliation&lt;/strong&gt;: When a user creates a booking, the UI updates immediately. The mutation queues if offline. Once connected, we reconcile the server state. If there's a conflict (say, the slot was booked by someone else), we show a specific error and revert with explanation, not a generic "something went wrong."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pseudo-pattern&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitBooking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Optimistic update&lt;/span&gt;
  &lt;span class="nf"&gt;updateLocalState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;updateUI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/bookings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isOffline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;queueForRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// UI stays optimistic until retry succeeds&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;revertLocalState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booking&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;showConflictModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Incremental sync&lt;/strong&gt;: Rather than full data refreshes, we sync only deltas. If you last synced at 3:42 PM and reconnect at 4:15 PM, we fetch changes from 3:42 PM forward, not the entire dataset. For Korean platforms handling millions of concurrent users, this is essential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compression and minimal payloads&lt;/strong&gt;: We aggressively compress responses. Our API returns CBOR instead of JSON for mobile clients when possible (26% smaller). We strip unnecessary fields at the API gateway level—a mobile client gets a different schema than a desktop client.&lt;/p&gt;

&lt;p&gt;The cost: more complexity in the backend and API versioning. The benefit: your mobile app works reliably in real-world conditions, and that reliability compounds into retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  State Management for Unreliable Networks
&lt;/h2&gt;

&lt;p&gt;Korean SaaS products deal with something Western founders often don't: a user base expecting your app to work &lt;em&gt;perfectly&lt;/em&gt; under imperfect conditions. This drove specific patterns.&lt;/p&gt;

&lt;p&gt;We use a three-layer state model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local cache&lt;/strong&gt; (SQLite on mobile): Single source of truth for what the device knows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending queue&lt;/strong&gt; (persistent storage): Actions waiting to sync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server state&lt;/strong&gt; (source of truth during connectivity)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When network returns, the queue processes sequentially with retry logic. We track mutation IDs to detect duplicates—if a request times out but actually succeeded, we don't process it twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Queue entry structure&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uuid-1234&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updateEvent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;New Title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1699564800000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is used by Kakao Talk, Line, and other Korean messaging apps. Your users expect read receipts, message delivery indicators, and guaranteed ordering even when switching between WiFi and cellular. Build for that from day one, and you've already won against competitors who treat mobile as secondary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database Design for Mobile Clients
&lt;/h2&gt;

&lt;p&gt;Korean SaaS founders think about database design differently because they're optimizing for millions of mobile clients hitting their API simultaneously. This shapes schema decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denormalization over normalization&lt;/strong&gt;: A user's profile includes their follower count, not a COUNT(*) query. A booking includes all required event details, not a foreign key reference. This trades storage for latency, and on mobile latency kills retention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexed for access patterns, not normalization&lt;/strong&gt;: Queries look like "give me all bookings for user X in the next 7 days" and "give me availability for service Y at location Z," not normalized relational queries. Indexes exist for these specific patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharding from the start&lt;/strong&gt;: Korean platforms shard by user ID or region from day one, not as a scaling afterthought. This prevents hot partitions and makes mobile queries predictably fast.&lt;/p&gt;

&lt;p&gt;For Saju, we shard by region (Seoul/Busan/etc.) and by date. A query for "all appointments tomorrow in Gangnam-gu" hits one partition and returns in &amp;lt;50ms. The same query on an unsharded database would timeout on mobile networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polish for Retention
&lt;/h2&gt;

&lt;p&gt;Korean mobile apps have absurdly high polish standards. Users notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Haptic feedback&lt;/strong&gt;: Every interaction provides subtle haptic confirmation. Users don't wonder if their tap registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline indicators&lt;/strong&gt;: A subtle visual indicator shows sync status. Red when offline, green when synced, yellow while pending. No confusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loading states&lt;/strong&gt;: Every async operation shows progress. Skeleton screens, not spinners. Korean users have grown up with snappy apps—a blank screen feels broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error recovery&lt;/strong&gt;: Errors aren't dead ends. They're recoverable states with clear next steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We spent 40 hours polishing error states and offline indicators. It's invisible when it works but devastating when absent. Korean competitors ship apps where every 1% of users doesn't experience loading spinners; they experience skeleton screens that feel instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Cost
&lt;/h2&gt;

&lt;p&gt;Building mobile-first architecture requires discipline. Your backend becomes more complex. Your API must support multiple schemas. Your deployment process needs to handle versioning for mobile clients that update on their own schedule, not yours.&lt;/p&gt;

&lt;p&gt;We've maintained two API versions for six months because 12% of users hadn't updated the app. That's the cost of serving diverse devices and network conditions.&lt;/p&gt;

&lt;p&gt;But the return is measurable: 68% of our active users access Saju exclusively through mobile. Our churn rate is 2.1% monthly, compared to industry averages of 5-8% for scheduling SaaS. That difference compounds.&lt;/p&gt;

&lt;p&gt;Korean mobile-first architecture isn't about trends—it's about pragmatism. When your market has no desktop escape hatch, you build for the constraints you actually have. For SaaS founders, adopting this mindset early means you're not retrofitting mobile later; you're building a product that happens to have a web interface.&lt;/p&gt;




&lt;p&gt;If you're building scheduling, booking, or any transaction-heavy SaaS, mobile-first architecture isn't optional anymore. The market expects apps that work offline, sync reliably, and feel native to the device. I'm building Saju exactly this way—if you want to see these patterns in practice, check out &lt;a href="https://sajuapp.app" rel="noopener noreferrer"&gt;https://sajuapp.app&lt;/a&gt; to see how these principles shape real product decisions.&lt;/p&gt;

</description>
      <category>korea</category>
      <category>fintech</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>True Solar Time in Code: Your Clock Is Not the Sun</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Wed, 29 Jul 2026 11:32:33 +0000</pubDate>
      <link>https://dev.to/kunstudio/true-solar-time-in-code-your-clock-is-not-the-sun-4dhj</link>
      <guid>https://dev.to/kunstudio/true-solar-time-in-code-your-clock-is-not-the-sun-4dhj</guid>
      <description>&lt;p&gt;If your code needs to know where the sun actually was at a given moment, the timestamp on the clock is not the number you want. Solar yield models, sundial software, golden-hour tools, agronomy models and traditional calendar systems all run into this. Civil time is an administrative decision. Solar time is astronomy. Two independent corrections sit between them.&lt;/p&gt;

&lt;p&gt;Neither correction is hard. What is easy to get wrong is the input you feed them, and I will get to that at the end, because it is the part that actually broke things for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction 1: you are not standing on the zone meridian
&lt;/h2&gt;

&lt;p&gt;A time zone is a band of longitude that agrees to share one clock. That clock is anchored to a meridian: UTC+9 is anchored to 135 degrees east. If you are not standing on 135E, your local noon is not the zone's noon.&lt;/p&gt;

&lt;p&gt;The Earth rotates 360 degrees in 1440 minutes, so one degree of longitude is four minutes of time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MINUTES_PER_DEGREE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;  &lt;span class="c1"&gt;# Earth turns 360 deg in 1440 min
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;longitude_correction_minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longitude_deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utc_offset_hours&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Minutes to add to clock time to reach local mean solar time.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;zone_meridian&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utc_offset_hours&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;15.0&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longitude_deg&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;zone_meridian&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;MINUTES_PER_DEGREE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Korea is the textbook case. It keeps UTC+9, anchored to 135E, but Seoul sits at roughly 126.978E. That is eight degrees of slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seoul     lon  126.9780  -&amp;gt;  -32.09 min
Tokyo     lon  139.6917  -&amp;gt;  +18.77 min
London    lon   -0.1276  -&amp;gt;   -0.51 min
New York  lon  -74.0060  -&amp;gt;   +3.98 min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seoul's sun runs about 32 minutes behind Seoul's clocks, permanently. London, sitting almost on the prime meridian, needs half a minute. This offset is fixed for a given location and zone, and it is not seasonal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correction 2: the equation of time
&lt;/h2&gt;

&lt;p&gt;The second correction is seasonal, and it is the more interesting one. Even a perfect sundial standing on the zone meridian disagrees with a perfect clock, because the Earth's orbit is elliptical and its axis is tilted. Apparent solar days are not all the same length. The accumulated difference is the equation of time, and it swings across roughly half an hour over the year.&lt;/p&gt;

&lt;p&gt;This is the NOAA Solar Calculator formulation. It is a truncated series rather than a full ephemeris, which makes it small enough to inline anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;julian_century&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;day_fraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minute&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;
    &lt;span class="n"&gt;jd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;365.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4716&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;30.6001&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;day_fraction&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1524.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;2451545.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;36525.0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;equation_of_time_minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;julian_century&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt_utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;l0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mf"&gt;280.46646&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;36000.76983&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.0003032&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mf"&gt;360.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;357.52911&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;35999.05029&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.0001537&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.016708634&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.000042037&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0000001267&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;21.448&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;46.8150&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.00059&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.001813&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;eps0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;23.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;26.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;
    &lt;span class="n"&gt;omega&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;125.04&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1934.136&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;eps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eps0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.00256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;omega&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eps&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;eot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate it before you trust it. Sweep a year and look for the extremes, which are well documented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max EoT 2026-11-03 16.49
min EoT 2026-02-11 -14.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Published tables put the annual minimum on Feb 11 near -14.24 minutes and the annual maximum in early November near +16.49 minutes. Matching to two decimal places is a good sign the series is transcribed correctly. If you port this and your February minimum comes out positive, you have a sign error in the eccentricity term.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting them together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;true_solar_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;longitude_deg&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;local_dt must be timezone-aware.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;offset_hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;local_dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;3600.0&lt;/span&gt;
    &lt;span class="n"&gt;lon_corr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;longitude_correction_minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;longitude_deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset_hours&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;eot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;equation_of_time_minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lon_corr&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eot&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;local_dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a clock reading of 23:30 in Seoul, across a year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clock (KST)    lon(min)   eot(min)      total   true solar time
2026-01-15       -32.09      -9.44     -41.53   2026-01-15 22:48:28
2026-04-15       -32.09       0.00     -32.09   2026-04-15 22:57:54
2026-07-15       -32.09      -6.03     -38.11   2026-07-15 22:51:53
2026-10-15       -32.09      14.27     -17.82   2026-10-15 23:12:10
2026-11-15       -32.09      15.43     -16.66   2026-11-15 23:13:20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The total correction moves between about -42 and -17 minutes depending on the date. A single hardcoded constant will be wrong by up to 25 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually bites: the offset is an input, not a constant
&lt;/h2&gt;

&lt;p&gt;Look at &lt;code&gt;true_solar_time&lt;/code&gt; again. It reads &lt;code&gt;utcoffset()&lt;/code&gt; off the datetime you hand it. Everything downstream depends on that being right, and this is where real code fails, because UTC offsets are historical facts rather than properties of a country.&lt;/p&gt;

&lt;p&gt;Korea changed its standard time more than once. It adopted UTC+08:30 in 1908, moved to UTC+09:00 in 1912, went back to UTC+08:30 in 1954, and returned to UTC+09:00 in 1961. It also ran summer time in several years. The IANA time zone database knows all of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zoneinfo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="n"&gt;tz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Asia/Seoul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1954&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1955&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1960&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1961&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1962&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1988&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1989&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;utcoffset&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1954 8:30:00
1955 9:30:00
1960 9:30:00
1961 8:30:00
1962 9:00:00
1988 10:00:00
1989 9:00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;July 1988 is UTC+10, not UTC+9, because South Korea observed daylight saving around the Seoul Olympics. Hardcode &lt;code&gt;timezone(timedelta(hours=9))&lt;/code&gt; and every summer 1987 and 1988 local time in your database is an hour off before the solar correction even runs.&lt;/p&gt;

&lt;p&gt;Here is what that costs. Traditional East Asian calendars bucket the day into twelve two-hour branches, so a shifted timestamp can land in a different bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hardcoded UTC+9      offset=9:00:00   lon=-32.09  eot=-5.95  total=-38.04  -&amp;gt; 21:21:57  branch=Pig
Asia/Seoul (tzdata)  offset=10:00:00  lon=-92.09  eot=-5.95  total=-98.04  -&amp;gt; 20:21:57  branch=Dog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same record, different answer. Note that the longitude correction itself also changed, from -32 to -92 minutes, because the zone meridian moved with the offset. The bug compounds rather than cancelling out.&lt;/p&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://sajuapp.app/en/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pipe_rotation&amp;amp;utm_content=true-solar-time-in-code" rel="noopener noreferrer"&gt;Cheonmyeongdang&lt;/a&gt;, a Korean Saju (Four Pillars) calculator that derives a chart from a birth date and hour. When the hour pillar is a two-hour bucket and your correction is half an hour wide, boundary cases are not rare, they are routine.&lt;/p&gt;

&lt;p&gt;The rule is short: never construct a fixed-offset timezone for a historical local time. Use &lt;code&gt;zoneinfo&lt;/code&gt; in Python, or the equivalent tz-aware type in your language, and let the database tell you the offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  JavaScript
&lt;/h2&gt;

&lt;p&gt;Same maths, and the offset lookup goes through &lt;code&gt;Intl&lt;/code&gt;, so it respects historical transitions too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;zoneOffsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeZoneName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;longOffset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;formatToParts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeZoneName&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "GMT+10:00"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/GMT&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;+-&lt;/span&gt;&lt;span class="se"&gt;])(\d{2})&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;?(\d{2})?&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Converting wall-clock fields to an instant needs one fixed-point step, since you cannot know the offset until you know the instant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wallClockToInstant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;zoneOffsetMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the 1988 case through the JavaScript port on Node 24:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Asia/Seoul  offset=UTC+10  lon=-92.09  eot=-5.95  total=-98.04  -&amp;gt; 20:21:57
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical to the Python, which is the check you want whenever you maintain two implementations of the same formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;The NOAA series is a low-precision approximation. It is fine for minute-level work and not intended for arcsecond astronomy, so reach for a real ephemeris if you need better.&lt;/p&gt;

&lt;p&gt;"Longitude of the city" is also a simplification. A city is not a point, and Seoul spans enough longitude to move the correction by a fraction of a minute. Pick a documented reference point and write it down rather than letting it drift between modules.&lt;/p&gt;

&lt;p&gt;Finally, decide explicitly whether your domain wants apparent solar time, which is both corrections, or local mean solar time, which is longitude only. Different traditions and technical standards answer that differently. Silently mixing the two across a codebase is worse than picking the less popular one and applying it consistently.&lt;/p&gt;

</description>
      <category>python</category>
      <category>javascript</category>
      <category>datetime</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Lichun Is Not Always February 4: Computing Solar Terms With Newton's Method</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:55:19 +0000</pubDate>
      <link>https://dev.to/kunstudio/lichun-is-not-always-february-4-computing-solar-terms-with-newtons-method-eb5</link>
      <guid>https://dev.to/kunstudio/lichun-is-not-always-february-4-computing-solar-terms-with-newtons-method-eb5</guid>
      <description>&lt;p&gt;Most calendar code that touches East Asian astrology has the same bug hiding in it: someone wrote down that Lichun (立春, the start of spring) falls on February 4, and moved on.&lt;/p&gt;

&lt;p&gt;It does not. In 2021 and 2025 it fell on February 3. In 2025 the exact moment was 23:10 KST — fifty minutes before midnight. Anyone born in that fifty-minute window gets a different year pillar and a different month pillar than a hardcoded table would give them. For a birth-chart engine, that is not a rounding error. That is the wrong chart.&lt;/p&gt;

&lt;p&gt;Here is what a solar term actually is, and how to compute one properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A solar term is an angle, not a date
&lt;/h2&gt;

&lt;p&gt;The 24 solar terms (절기 / 節氣) are not calendar conventions. Each one is the instant at which the sun's &lt;strong&gt;apparent ecliptic longitude&lt;/strong&gt; reaches a multiple of 15 degrees. Lichun is 315 degrees. The vernal equinox is 0. There are 24 of them because 360 / 15 = 24.&lt;/p&gt;

&lt;p&gt;That definition has two consequences that matter for implementation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The instant is &lt;strong&gt;global&lt;/strong&gt;. It is one moment in physical time, the same for every observer on Earth. Only its local clock representation differs.&lt;/li&gt;
&lt;li&gt;The instant does &lt;strong&gt;not&lt;/strong&gt; land on a tidy calendar boundary. It drifts year to year because the tropical year is not 365 days, and leap years shove it around.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the question "what date is Lichun in year Y" is really "solve for the time &lt;code&gt;t&lt;/code&gt; where &lt;code&gt;apparentSolarLongitude(t) == 315&lt;/code&gt;, then render &lt;code&gt;t&lt;/code&gt; in the target timezone."&lt;/p&gt;

&lt;p&gt;That is a root-finding problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: apparent solar longitude
&lt;/h2&gt;

&lt;p&gt;I use the low-precision solar series from Jean Meeus, &lt;em&gt;Astronomical Algorithms&lt;/em&gt; (2nd ed.). Given a Julian Ephemeris Day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sunApparentLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jde&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;J2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;36525.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;L0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rev360&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;280.46646&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;36000.76983&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0003032&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;M&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rev360&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;357.52911&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;35999.05029&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.0001537&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Mr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;M&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;D2R&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;C&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.914602&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.004817&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.000014&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Mr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.019993&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.000101&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;Mr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
     &lt;span class="mf"&gt;0.000289&lt;/span&gt;                                    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;Mr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trueLong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;L0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;omega&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;125.04&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1934.136&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rev360&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trueLong&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.00569&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.00478&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;omega&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;D2R&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;L0&lt;/code&gt; is the geometric mean longitude, &lt;code&gt;M&lt;/code&gt; the mean anomaly, &lt;code&gt;C&lt;/code&gt; the equation of the center. The final line is what makes it &lt;em&gt;apparent&lt;/em&gt; rather than &lt;em&gt;true&lt;/em&gt;: &lt;code&gt;-0.00569&lt;/code&gt; is aberration, and the &lt;code&gt;omega&lt;/code&gt; term is nutation in longitude. Skip those two and you are off by roughly 20 arcseconds, which is about seven minutes of time. Usually harmless, occasionally the difference between February 3 and February 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Newton's method on a circle
&lt;/h2&gt;

&lt;p&gt;Now solve &lt;code&gt;sunApparentLongitude(jde) == targetLon&lt;/code&gt;. The derivative is well-behaved (the sun moves about 1 degree per day, always forward), so plain Newton converges in a handful of iterations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;solveLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetLon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jdeGuess&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jdeGuess&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sunApparentLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jde&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;targetLon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sunApparentLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;sunApparentLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;  &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;corr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;corr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;corr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jde&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;while&lt;/code&gt; loops wrapping &lt;code&gt;diff&lt;/code&gt; and &lt;code&gt;d1&lt;/code&gt; into [-180, 180] are the entire trick, and they are the thing people get wrong. Longitude is modular. Near 0 degrees the naive difference between 359.9 and 0.1 is -359.8, which sends Newton flying off to the wrong year. Normalizing the &lt;em&gt;difference&lt;/em&gt; rather than the values keeps the step honest across the wrap point. The same normalization is needed on the numerical derivative for the same reason.&lt;/p&gt;

&lt;p&gt;I use a central difference (&lt;code&gt;jde + h&lt;/code&gt; and &lt;code&gt;jde - h&lt;/code&gt;) instead of deriving the series analytically. It costs one extra evaluation per iteration and removes a whole class of transcription errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the part everyone forgets, delta-T
&lt;/h2&gt;

&lt;p&gt;Newton gives an answer in &lt;strong&gt;Terrestrial Time&lt;/strong&gt;. Civil clocks run on &lt;strong&gt;Universal Time&lt;/strong&gt;. The gap between them, ΔT = TT − UT, is not a constant — it tracks the irregular rotation of the Earth, and it is currently around 69 seconds and growing.&lt;/p&gt;

&lt;p&gt;Sixty-nine seconds sounds ignorable until the term lands at 23:59:30 local. Then it moves the date by a day, and the date is the thing the chart is keyed on. So it gets applied as a piecewise polynomial in the year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2005&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2050&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;62.92&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.32217&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.005589&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with separate branches for earlier epochs, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deltaTSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;approx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;approx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jdKST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// TT -&amp;gt; UT -&amp;gt; KST&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subtract ΔT to get UT, add 9 hours for KST, and only then convert to a calendar date.&lt;/p&gt;

&lt;p&gt;Note the ordering. Converting to a date first and adjusting afterwards is the bug that produces off-by-one days at year boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: guess the right year
&lt;/h2&gt;

&lt;p&gt;One more trap. Seeding Newton from a day-of-year approximation can converge into the neighbouring year's occurrence of the same longitude, because 315 degrees happens every year. The fix is to probe and verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;probeYear&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;approxDoy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;79&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;365.2422&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;approxDoy&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;365.2422&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;approxDoy&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mf"&gt;365.2422&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jde&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;solveLongitude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;gregorianToJD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;probeYear&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;approxDoy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...convert to KST...&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// converged into a neighbour, try again&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;79&lt;/code&gt; offset is the approximate day-of-year of the vernal equinox, which is where ecliptic longitude 0 sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;Claiming astronomical accuracy without checking it is how these bugs survive. I ran all 12 major terms across 1920–2050 against Korea's public calendar API and the dates match exactly across the whole range.&lt;/p&gt;

&lt;p&gt;On precision: the date is exact throughout. The &lt;em&gt;minute&lt;/em&gt; can differ by up to roughly 15 minutes against a full VSOP87 solution, because the Meeus low-precision series carries about 0.01 degrees of error. For pillar boundaries that is irrelevant — the boundary is evaluated at day granularity. If you are building something that needs the minute, use the full series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;The honest answer is that the hardcoded version is right most of the time. Lichun is February 4 in the majority of years, so the table passes casual testing and fails silently for the people born on the edge — who are exactly the people most likely to notice their chart is wrong.&lt;/p&gt;

&lt;p&gt;Roughly 130 lines of astronomy replaces the table and removes the failure mode. It is self-contained, has no dependencies, and runs fine at the edge; ours executes inside a Cloudflare Function.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://sajuapp.app/en/" rel="noopener noreferrer"&gt;Cheonmyeongdang&lt;/a&gt;, a Korean Saju (four pillars) reading service, and this calculation sits under every chart it produces. If you want to see the boundary behaviour without installing anything, the &lt;a href="https://sajuapp.app/en/saju" rel="noopener noreferrer"&gt;English chart calculator&lt;/a&gt; is free and needs no signup — try a birth date on February 3 or 4 of 2021 or 2025 and watch the month pillar change.&lt;/p&gt;

&lt;p&gt;The general lesson transfers beyond astrology: when a date in your domain is defined by a physical event rather than a calendar rule, storing it as a constant is a bug with a delayed fuse.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>algorithms</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Naver/Kakao dominate Korean search and what global devs miss</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 27 Jul 2026 00:00:13 +0000</pubDate>
      <link>https://dev.to/kunstudio/why-naverkakao-dominate-korean-search-and-what-global-devs-miss-55jh</link>
      <guid>https://dev.to/kunstudio/why-naverkakao-dominate-korean-search-and-what-global-devs-miss-55jh</guid>
      <description>&lt;h2&gt;
  
  
  The Korean Search Puzzle: Why Google Lost to Local Players
&lt;/h2&gt;

&lt;p&gt;When I first started building products in Korea, I made an embarrassing mistake. I assumed Naver and Kakao were just "Korean alternatives to Google." They're not. They're different beasts entirely, optimized for how Koreans actually search, communicate, and discover information. Google's market share in Korea sits around 3-5%, while Naver commands roughly 60-70% of desktop search. For mobile, the picture fragments further—Kakao Talk's integrated search and commerce features matter as much as traditional search engines.&lt;/p&gt;

&lt;p&gt;This isn't about inferior technology. It's about understanding why network effects and local infrastructure create advantages that are genuinely difficult for foreign competitors to overcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naver's Ecosystem Lock-in: More Than Search
&lt;/h2&gt;

&lt;p&gt;Naver started as a portal in 1999, but calling it a "search engine" undersells what it became. The platform evolved into an ecosystem where users don't just search—they live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naver Blog&lt;/strong&gt; is the critical piece. Unlike Medium or dev.to, Naver Blog had 40+ million registered users at its peak and became the default place Koreans publish long-form content, product reviews, and tutorials. When you search on Naver, you're not getting indexed web pages like Google. You're pulling from Naver's closed ecosystem: Naver Blog posts, Naver Cafe (community forums), Naver Knowledge iN (Q&amp;amp;A), and Naver Shopping integration. This creates a self-reinforcing loop—content creators publish on Naver Blog because that's where Naver search drives traffic. Users search on Naver because that's where the content is.&lt;/p&gt;

&lt;p&gt;Try searching for Korean restaurant reviews or product recommendations. You'll find Naver Blog posts ranking first. Google's algorithm would rank Wikipedia, Reddit, or review sites. Naver ranks content within its own network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naver Shopping&lt;/strong&gt; integration further cements this. When you search for a product, Naver doesn't just link you to retail sites—it surfaces prices, reviews, and purchase options directly within Naver Shopping, taking a percentage of transactions. For Korean e-commerce businesses, Naver Shopping advertising (called "Product Ads") is non-negotiable. It's where customers search and convert.&lt;/p&gt;

&lt;p&gt;The technical implication: Naver's entire ranking system optimizes for content within its platform, not the open web. PageRank doesn't apply. Backlinks from outside Naver matter less than community engagement metrics within Naver's ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kakao's Infrastructure Play: WeChat Before WeChat
&lt;/h2&gt;

&lt;p&gt;Kakao's dominance comes from a different angle—platform integration and messaging infrastructure.&lt;/p&gt;

&lt;p&gt;Kakao Talk (messaging app) grew to over 50 million daily active users in Korea by the mid-2010s. The company realized they owned the layer through which Koreans communicate. They embedded search, commerce, and content discovery directly into the app. &lt;strong&gt;Kakao Map&lt;/strong&gt; became more useful than Google Maps for Korean users because businesses are indexed there, customer reviews are aggregated, and payment integration is seamless.&lt;/p&gt;

&lt;p&gt;From a developer perspective, here's what matters: Kakao's business model depends on owning the transaction layer. When a Korean user discovers a restaurant via Kakao Map, books a table through Kakao Talk's restaurant bot, and pays via Kakao Pay—Kakao captures data and fees at every step. Google's search model, while powerful globally, generates money through advertising. Kakao generates money through transaction volume.&lt;/p&gt;

&lt;p&gt;This shapes how their search algorithms work. Kakao prioritizes discoverable businesses and services that drive transactions, not just relevant information. If you're a small cafe with 100 menu items indexed on Kakao Map, you're more discoverable than a high-quality but not-yet-indexed competitor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kakao Brain&lt;/strong&gt; investments in AI and generative models suggest they're building toward conversational discovery—users asking Kakao Talk bot for recommendations rather than typing search queries. The early versions show how this reshapes search intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network Effects and Regulatory Protection
&lt;/h2&gt;

&lt;p&gt;Both Naver and Kakao benefit from regulatory factors that Western tech didn't have to navigate the same way.&lt;/p&gt;

&lt;p&gt;Korea's adoption of ISP registration requirements and strict regulations around foreign payment processors created barriers for international players. But more importantly, Korean users simply trusted local companies with their data and payments earlier than Western users did. By the time Google wanted to seriously invest in Korean search around 2010, Naver and Kakao had already captured user behavior.&lt;/p&gt;

&lt;p&gt;Network effects then become unbeatable. If 65% of Korean users search on Naver, 65% of Korean content creators optimize for Naver. If they optimize for Naver, more users have reason to search there. Google entering this market required breaking trust &lt;em&gt;and&lt;/em&gt; convincing creators to optimize for Google &lt;em&gt;while&lt;/em&gt; still optimizing for Naver for their primary audience.&lt;/p&gt;

&lt;p&gt;The regulatory angle also mattered technically. Korea's Real Name Registration System and other identity verification requirements meant these platforms had higher-quality user data for personalization than Google initially did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Global Developers Miss
&lt;/h2&gt;

&lt;p&gt;The strategic lesson for developers building for or in Korea:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. SEO means Naver and Kakao, not Google.&lt;/strong&gt; If your target market is Korean users, allocate resources accordingly. Naver Blog syndication, Naver Shopping optimization, and Kakao Map listing are non-negotiable. Global SEO best practices transfer maybe 30% to this market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Transaction-driven ranking beats relevance-driven ranking.&lt;/strong&gt; Traditional SEO assumes search engines want to surface the most relevant result. Naver and Kakao want to surface results that drive monetizable actions. This means small businesses see better ROI optimizing for transaction intent rather than organic search volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Closed ecosystems compound faster than open ones.&lt;/strong&gt; Google's strength is indexing the entire web. Naver and Kakao's strength is that they don't have to—they built ecosystems where the content they want ranked is created on their platforms. Building &lt;em&gt;into&lt;/em&gt; these ecosystems rather than against them matters more than search optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Mobile and messaging layer changes discovery patterns.&lt;/strong&gt; A significant portion of Korean search now happens through Kakao Talk bots and mobile apps, not search.naver.com. Optimization means API integration and bot development, not just content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Framework for Korean Market Entry
&lt;/h2&gt;

&lt;p&gt;If you're building a product for Korean users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Months 1-3:&lt;/strong&gt; Get indexed on Naver and Kakao Map. This takes longer than Google indexing (4-8 weeks typically). Start immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Months 2-6:&lt;/strong&gt; Build a Kakao Talk bot or Kakao Map integration. If you're a service business, Kakao Map is non-negotiable. If you're B2B SaaS selling to Koreans, consider a Kakao Talk bot for customer support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ongoing:&lt;/strong&gt; Monitor Naver Search Advisor and Kakao Business Console like you'd monitor Google Search Console. The interfaces are different. The data signals are different.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uncomfortable truth: Naver and Kakao's dominance isn't temporary. They've made mistakes (Naver's poor mobile transition in the early 2010s, Kakao's overexpansion), but both remain entrenched. New competitors emerge—Coupang's search for commerce, Naver's own TikTok alternative competition—but the core search and discovery layer remains their territory.&lt;/p&gt;

&lt;p&gt;If you're building in Korea or serving Korean users, treat this market as fundamentally different from Western markets. The search and discovery layer isn't Google. It's Naver for informational queries, Kakao for transactional discovery, and increasingly, social platforms for recommendations. Optimizing for this reality is the difference between traction and invisibility.&lt;/p&gt;

&lt;p&gt;For teams navigating Korean market entry, I've built tools to help developers understand these dynamics better. At &lt;a href="https://sajuapp.app" rel="noopener noreferrer"&gt;Saju&lt;/a&gt;, we're working on making Korean market analysis more accessible—giving builders data on what actually works in Korean search and commerce before they invest months chasing the wrong optimization vectors.&lt;/p&gt;

</description>
      <category>korea</category>
      <category>fintech</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Math Behind a Late-Fee: Turning "Net 30" Into an Enforceable Number</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:37:29 +0000</pubDate>
      <link>https://dev.to/kunstudio/the-math-behind-a-late-fee-turning-net-30-into-an-enforceable-number-5bg6</link>
      <guid>https://dev.to/kunstudio/the-math-behind-a-late-fee-turning-net-30-into-an-enforceable-number-5bg6</guid>
      <description>&lt;h1&gt;
  
  
  The math behind a late-fee: turning "net 30" into an enforceable number
&lt;/h1&gt;

&lt;p&gt;I build small web tools, and the one that surprised me most wasn't AI-heavy at all — it was a late-fee calculator for freelancers. The hard part wasn't the arithmetic. It was deciding &lt;em&gt;which&lt;/em&gt; arithmetic is defensible enough to put in front of a client who is already 45 days late.&lt;/p&gt;

&lt;p&gt;This is a short write-up of the reasoning, with the actual formula I shipped in &lt;a href="https://invoice-rescue.pages.dev/" rel="noopener noreferrer"&gt;Invoice Rescue&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simple interest, day-count, no compounding
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for compound interest. Don't. For overdue B2B invoices, a client-facing fee that compounds daily reads as punitive and invites a dispute. Simple interest on a daily accrual is both easier to justify and closer to how most late-payment statutes actually describe it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lateFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annualRatePct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;daysOverdue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;annualRatePct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;dailyRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;daysOverdue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fee&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// $2,500 invoice, 18%/yr, 20 days late → $24.66&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions are doing the work here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Math.max(0, daysOverdue)&lt;/code&gt;&lt;/strong&gt; — the due date is in the future for a &lt;em&gt;lot&lt;/em&gt; of "check the fee" sessions (people model it before it's actually late). A naive &lt;code&gt;today - dueDate&lt;/code&gt; goes negative and produces a phantom credit. Clamp it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/ 365&lt;/code&gt;&lt;/strong&gt; — some contracts and jurisdictions use a 360-day "banker's year." Pick one, state it in the output, and don't silently switch. The number a client sees has to match the number in the clause you send them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part everyone skips: the day-count is a UX problem, not a math problem
&lt;/h2&gt;

&lt;p&gt;When someone types a due date, they mean &lt;em&gt;their&lt;/em&gt; timezone and &lt;em&gt;their&lt;/em&gt; calendar day. If you diff two &lt;code&gt;Date&lt;/code&gt; objects you'll get an off-by-one every time the browser TZ and the invoice TZ disagree at midnight. Compare calendar dates, not timestamps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;daysBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dueISO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dueISO&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;T00:00:00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The reminder sequence is a state machine, not three templates
&lt;/h2&gt;

&lt;p&gt;The other half of getting paid is the follow-up, and the mistake is treating "polite / firm / final" as three static emails. They're a state machine with one input that changes tone: &lt;em&gt;how much rapport you can still afford to spend.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polite&lt;/strong&gt; (day 1–7 overdue): assume an oversight. No fee mentioned. One clear ask, one clear due date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firm&lt;/strong&gt; (day 8–21): name the amount, the invoice number, and the days overdue as facts. Introduce the fee clause &lt;em&gt;as information&lt;/em&gt;, not a threat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final&lt;/strong&gt; (22+): state the next concrete step and a hard date. Still no insults — a final notice that stays professional is the one that survives being forwarded to their accounts team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that makes this convert is that every message references the specific invoice and the specific number. Generic "just following up!" emails get ignored precisely because they carry no cost signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;p&gt;Don't compound, clamp the day-count, compare calendar dates, and keep the final notice boring. The tool that came out of this reasoning is &lt;a href="https://invoice-rescue.pages.dev/" rel="noopener noreferrer"&gt;Invoice Rescue&lt;/a&gt; — the fee calculator and the three-step templates are free to use with no signup; a paid AI pack writes the full chase sequence for a specific situation. But honestly, the four rules above are the whole product. The rest is just typing.&lt;/p&gt;

&lt;p&gt;If you've got an invoice sitting past due right now, &lt;a href="https://invoice-rescue.pages.dev/" rel="noopener noreferrer"&gt;run the numbers on Invoice Rescue&lt;/a&gt; and start with the polite template today — the longer you wait, the more the tone has to escalate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the KunStudio team. We build small, focused web tools. This is general information about invoicing workflows, not legal or financial advice — check your contract and local law for the enforceable rate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>freelance</category>
    </item>
    <item>
      <title>How Korean fintech (Toss / KakaoPay / PortOne) actually works under the hood</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Mon, 20 Jul 2026 00:00:07 +0000</pubDate>
      <link>https://dev.to/kunstudio/how-korean-fintech-toss-kakaopay-portone-actually-works-under-the-hood-3a70</link>
      <guid>https://dev.to/kunstudio/how-korean-fintech-toss-kakaopay-portone-actually-works-under-the-hood-3a70</guid>
      <description>&lt;h2&gt;
  
  
  The Architecture Behind Korea's Fintech Giants: How Toss, KakaoPay, and PortOne Actually Work
&lt;/h2&gt;

&lt;p&gt;Korean fintech companies like Toss, KakaoPay, and PortOne have become synonymous with financial innovation, but most developers only see the polished UI. I've spent months reverse-engineering their payment flows, API patterns, and infrastructure choices while building payment features for my own products. Here's what actually happens when you tap "send money" in Toss or click "pay" on PortOne.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the Three-Layer Korean Fintech Model
&lt;/h2&gt;

&lt;p&gt;Korean fintech operates within a regulatory framework that forces a specific architectural pattern: the three-layer stack. Unlike other countries, Korea mandates strict separation between payment service providers (PG), payment intermediaries, and actual banks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Frontend &amp;amp; User Interface&lt;/strong&gt;&lt;br&gt;
This is what users see. Toss's interface is famously minimal—a transfer takes maybe 3 taps. But under the hood, the frontend performs several crucial steps: biometric authentication (fingerprint, face recognition via Knox on Samsung devices), device binding validation, and transaction signing. Toss uses a combination of WebAuthn for web and proprietary SDK integration for mobile apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Payment Service Provider (PG)&lt;/strong&gt;&lt;br&gt;
PortOne (formerly I'mport) sits here. They don't actually hold money—they're orchestrators. When you initiate a payment through PortOne's API, you're hitting their unified payment gateway that routes to the actual acquiring banks. PortOne normalizes requests across 50+ Korean payment methods into a single API surface. Their core business is handling the translation layer between merchants and banks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Banking Infrastructure&lt;/strong&gt;&lt;br&gt;
The actual money movement happens through Korea's interbank network. Toss itself operates as a "virtual bank" (인터넷 전문은행) licensed by the FSC since 2021. This is critical—they can hold customer deposits directly, which KakaoPay cannot. This explains why Toss launched checking accounts and why KakaoPay had to partner with Kakao Bank.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Real-Time Clearing System: A-Wait-FTS
&lt;/h2&gt;

&lt;p&gt;The backbone of Korean money movement isn't SWIFT or ACH. It's &lt;strong&gt;A-Wait-FTS&lt;/strong&gt; (Advanced Waiting Financial Telecom System), the interbank settlement platform operated by the Korean Financial Telecommunications and Clearings Institute. Think of it as Korea's version of Fedwire, but with specific requirements for fintech.&lt;/p&gt;

&lt;p&gt;When you send ₩10,000 from a Toss account to another bank, here's the actual sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authentication&lt;/strong&gt; (~100ms): Multi-factor auth combines something you have (device), something you know (PIN), and something you are (biometrics). Toss uses device binding—your phone is registered with your account. A transaction from an unregistered device triggers additional verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transaction Signing&lt;/strong&gt; (~50ms): The request is signed using PKI (Public Key Infrastructure). Korean regulations require specific encryption standards—primarily AES-256 for transmission and RSA-2048 for key exchange.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Message Format Conversion&lt;/strong&gt; (~30ms): PortOne or the direct bank connection formats the request into SWIFT-like messages, but with Korean-specific fields. Bank codes use the 3-digit BIC system (우리은행=020, 국민은행=004, etc.).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real-Time Settlement&lt;/strong&gt; (~1-3 seconds): The money moves through A-Wait-FTS. Unlike older batch systems, A-Wait-FTS processes transfers individually and immediately. Most Korean transfers complete in under 10 seconds, often faster than 5 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Notification&lt;/strong&gt; (~100ms): Both sides receive settlement confirmations. Apps display "전송 완료" (transfer complete) almost instantly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entire flow completes in 5-10 seconds, which is why Korean users expect instant transfers. This is standard, not exceptional.&lt;/p&gt;
&lt;h2&gt;
  
  
  How KakaoPay Handles Money Without Being a Bank
&lt;/h2&gt;

&lt;p&gt;KakaoPay's architecture reveals how fintech navigates regulatory constraints. They operate as a &lt;strong&gt;payment service intermediary&lt;/strong&gt; (결제중개사), not a bank. This means they cannot legally hold customer money.&lt;/p&gt;

&lt;p&gt;Their workaround: custodial banking relationships. When you load ₩100,000 into KakaoPay, it's actually held by partner banks (신한은행, 우리은행, 국민은행). The frontend shows one unified wallet, but the backend maintains separate ledgers per partner bank. Kakao's system determines which partner bank holds which funds based on customer velocity and liquidity forecasts.&lt;/p&gt;

&lt;p&gt;When you send money from KakaoPay to another user, two scenarios occur:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If recipient is also on KakaoPay:&lt;/strong&gt; Internal ledger transfer, instant, no clearing required. Just a database update in Kakao's system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If recipient is on another platform or bank:&lt;/strong&gt; Actual clearing through A-Wait-FTS, incurring fees. This is why KakaoPay encourages transfers between KakaoPay users—zero fees.&lt;/p&gt;

&lt;p&gt;The fee structure here is telling: ₩0 for in-network, ₩1,000 for cross-network transfers under ₩1,000,000. That difference is literally the cost of A-Wait-FTS clearing.&lt;/p&gt;
&lt;h2&gt;
  
  
  PortOne's API Abstraction Layer
&lt;/h2&gt;

&lt;p&gt;PortOne's technical value isn't flashy, but it's where the real engineering happens. They normalize payment methods across different banks and PGs.&lt;/p&gt;

&lt;p&gt;A merchant integrating PortOne makes one API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;IMP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_pay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;danal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pay_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;merchant_uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20231015-000001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;상품명&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;구매자명&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;buyer_tel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;010-1234-5678&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rsp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rsp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// payment success&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind this single call, PortOne:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Routes to the appropriate PG (Danal, KCP, NICE, etc.) based on pg parameter and inventory logic&lt;/li&gt;
&lt;li&gt;Handles method-specific quirks (일반결제 vs 정기결제 parameters differ per PG)&lt;/li&gt;
&lt;li&gt;Manages token storage for recurring payments&lt;/li&gt;
&lt;li&gt;Normalizes response formats (each PG returns different field names)&lt;/li&gt;
&lt;li&gt;Handles escrow payments, which are common for marketplace transactions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They process ~₩20 trillion annually (2023 numbers), roughly 40% of Korean online transactions. That volume requires serious infrastructure—they use redundant data centers, real-time failover to backup PGs, and maintain &amp;lt;99.99% uptime SLA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: Beyond HTTPS
&lt;/h2&gt;

&lt;p&gt;Korean fintech security goes beyond standard TLS. It mandates specific practices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card Information Never Touches Your Server&lt;/strong&gt;: All sensitive payment data goes directly to the PG's secure servers via client-side encryption. This is non-negotiable under PCI DSS + Korean regulations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token-Based Transactions&lt;/strong&gt;: After initial payment, merchants receive tokens. Subsequent transactions use tokens, never raw card data. PortOne manages this token lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device Binding&lt;/strong&gt;: A payment initiated from an unknown device automatically triggers additional verification. Toss stores device fingerprints (hardware identifiers, OS, installed apps) to detect anomalies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-Time Fraud Detection&lt;/strong&gt;: Most platforms use ML-based scoring (Datamines, SK Infosec, etc.). Transaction flagged as suspicious? SMS 2FA required. This adds 10-30 seconds but is invisible if legitimate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encryption Standards&lt;/strong&gt;: The regulatory default is AES-256-CBC for data transmission, RSA-2048 for key exchange. Older MD5 hashing is explicitly forbidden since 2015.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Integration Speed Still Matters
&lt;/h2&gt;

&lt;p&gt;Despite standardization, Korean fintech integration takes 1-2 weeks for a competent engineer, not days. Why?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Certification delays&lt;/strong&gt;: Your merchant registration can't process payments until the PG's compliance team reviews your business (3-5 days).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test-mode requirements&lt;/strong&gt;: Each PG's sandbox has different test card numbers, amounts that trigger specific responses. Test card 4111-1111-1111-1111 works at one PG, fails at another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring payment setup&lt;/strong&gt;: If you need subscriptions, the setup flow diverges dramatically between PGs. NICE requires explicit API keys per subscription type, while Danal doesn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building payment infrastructure in Korea is pragmatic engineering. You're not fighting the system—you're working within it. Toss, KakaoPay, and PortOne succeeded not through technical wizardry, but by understanding Korean financial regulations intimately and building systems that respect them while delivering exceptional user experience.&lt;/p&gt;

&lt;p&gt;If you're building payment features for Korean users or expanding into Korea, integrating properly from the start saves months later. Understanding these three layers—user interface, PG orchestration, and banking infrastructure—shapes every architectural decision.&lt;/p&gt;

&lt;p&gt;I'm currently building Saju, a fintech dashboard for Korean solo founders and small businesses to manage cash flow across multiple payment platforms. We went through this exact integration nightmare, and I'm documenting lessons learned at &lt;a href="https://sajuapp.app" rel="noopener noreferrer"&gt;https://sajuapp.app&lt;/a&gt;. If you're navigating Korean payment infrastructure for the first time, that might save you days of debugging.&lt;/p&gt;

</description>
      <category>korea</category>
      <category>fintech</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two SKUs, One Model: How Prompt Engineering Alone Creates a $19 vs $29 SOP Generator</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:23:39 +0000</pubDate>
      <link>https://dev.to/kunstudio/two-skus-one-model-how-prompt-engineering-alone-creates-a-19-vs-29-sop-generator-3ek</link>
      <guid>https://dev.to/kunstudio/two-skus-one-model-how-prompt-engineering-alone-creates-a-19-vs-29-sop-generator-3ek</guid>
      <description>&lt;h1&gt;
  
  
  Two SKUs, One Model: How Prompt Engineering Alone Creates a $19 vs $29 SOP Generator
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-sop-builder.pages.dev/" rel="noopener noreferrer"&gt;AI SOP Builder&lt;/a&gt; turns a plain-language process description into a finished Standard Operating Procedure. There's no fine-tuned model and no separate "pro" pipeline — it's a single Claude Haiku call, and the entire product differentiation between the $19 and $29 tiers lives in how the prompt is assembled.&lt;/p&gt;

&lt;h2&gt;
  
  
  One generation function, two tiers
&lt;/h2&gt;

&lt;p&gt;Both SKUs — &lt;code&gt;single&lt;/code&gt; ($19, standard) and &lt;code&gt;pro&lt;/code&gt; ($29, "audit-ready") — route through the same &lt;code&gt;generateSOP()&lt;/code&gt; function and the same model (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;, with an optional &lt;code&gt;SOP_MODEL_PRO&lt;/code&gt; env override). What changes is a &lt;code&gt;tier&lt;/code&gt; string threaded through the prompt builders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are a senior operations and quality-management specialist who writes Standard &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Operating Procedures (SOPs) that pass ISO 9001-style audits...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Never invent fake statistics, regulation numbers, certification IDs, or citations. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;If a step depends on a policy you were not given, refer to it generically (for example &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'per your data-retention policy') and flag it as something the owner must fill in...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is the audit-ready edition: add a compact RACI view ... explicit controls, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkpoints, and risk/compliance notes throughout the Procedure, and make the &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Quality Checklist thorough enough to serve as a sign-off sheet.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Keep it practical, complete, and immediately usable by a small team.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pro doesn't get a different retrieval source or bigger context window — it gets one extra paragraph telling the same model to add a RACI matrix (Responsible/Accountable/Consulted/Informed), inline compliance notes, and a heavier sign-off checklist. The anti-hallucination instruction — never invent a regulation number or certification ID, flag anything unprovided as something "the owner must fill in" — applies to both tiers, since a fabricated ISO clause in an audit file is worse than an SOP that's honestly generic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free preview is a capped, separate generation — not a truncated one
&lt;/h2&gt;

&lt;p&gt;Before payment, &lt;code&gt;/api/preview&lt;/code&gt; generates only the "Purpose and Scope" section, capped at 240 output tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSOP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;previewOnly&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SOP_MODEL_PRO&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;claudeGenerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previewOnly&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;5200&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't the full SOP truncated client-side — in &lt;code&gt;previewOnly&lt;/code&gt; mode the prompt itself tells the model to write nothing but that section ("write nothing else — no other section, no numbered steps"). The visitor reads something genuinely specific to their process before paying anything, which is the point: a preview obviously tailored to &lt;em&gt;your&lt;/em&gt; input converts differently than a generic marketing screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fulfillment happens inline with payment capture — deliberately
&lt;/h2&gt;

&lt;p&gt;There's no separate "generate" step after checkout. &lt;code&gt;capture-order.js&lt;/code&gt; captures the PayPal order and, in the same request, calls &lt;code&gt;generateSOP()&lt;/code&gt; for the full document before responding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateSOP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;processing_error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;support&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;processingError&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghdejr11@gmail.com (auto-refund if unfulfilled)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line: &lt;code&gt;status: "COMPLETED"&lt;/code&gt; still returns even if generation throws — the payment succeeded, so the response says so, with a &lt;code&gt;processing_error&lt;/code&gt; flag and a support contact instead of silently losing the fact that money changed hands. A source comment ties this directly to a lesson from a prior pipe where payments succeeded while fulfillment silently failed with no record of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering the result without a Markdown library
&lt;/h2&gt;

&lt;p&gt;The SOP comes back as Markdown, and the front end renders it with a small hand-written parser — line-by-line regex matching for headings (&lt;code&gt;^#{1,4}\s&lt;/code&gt;), numbered lists (&lt;code&gt;^\d+[.)]\s&lt;/code&gt;), bullets (&lt;code&gt;^[-*]\s&lt;/code&gt;), and &lt;code&gt;**bold**&lt;/code&gt; spans — built entirely with &lt;code&gt;createElement&lt;/code&gt;/&lt;code&gt;textContent&lt;/code&gt;, never &lt;code&gt;innerHTML&lt;/code&gt;. It's a deliberately small vocabulary because that's the entire output format the system prompt requires — "render-safe Markdown only... no tables, code fences, or HTML" — so the renderer only has to handle exactly what the model is told to produce.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-sop-builder.pages.dev/" rel="noopener noreferrer"&gt;https://ai-sop-builder.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Redesigning a Yard Without Touching the House: Structure-Preserving AI Image Edits With FLUX.2 [pro]</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:23:02 +0000</pubDate>
      <link>https://dev.to/kunstudio/redesigning-a-yard-without-touching-the-house-structure-preserving-ai-image-edits-with-flux2-pro-4nh6</link>
      <guid>https://dev.to/kunstudio/redesigning-a-yard-without-touching-the-house-structure-preserving-ai-image-edits-with-flux2-pro-4nh6</guid>
      <description>&lt;h1&gt;
  
  
  Redesigning a Yard Without Touching the House: Structure-Preserving AI Image Edits With FLUX.2 [pro]
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-garden-design.pages.dev/" rel="noopener noreferrer"&gt;AI Garden Design&lt;/a&gt; takes a photo of a front yard, backyard, or facade and re-renders the landscaping in a different style — modern, cottage, Japanese zen, desert xeriscape, tropical — while leaving the house itself alone. That constraint, "leave the house alone," turned out to be the whole engineering problem. Nobody wants a "redesign" that also invents a different roofline or moves the driveway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model choice: an edit model, not a fresh generation
&lt;/h2&gt;

&lt;p&gt;This isn't text-to-image, it's image-to-image editing — specifically &lt;code&gt;fal-ai/flux-2-pro/edit&lt;/code&gt;, a multi-reference, structure-preserving photoreal edit model. The uploaded photo is the reference image, and &lt;code&gt;image_size: "auto"&lt;/code&gt; matches the output aspect ratio to the input instead of forcing a fixed size, which matters when the same house has to stay in the same position in the frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;falRedesign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;falQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/flux-2-pro/edit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;image_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;safety_tolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;falUpscale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;safety_tolerance: "5"&lt;/code&gt; (the most permissive setting) exists for a boring reason: default safety filters on image-edit models are tuned for far broader content than "photo of a lawn," and even benign yard photos were tripping false positives without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserving structure is a prompting problem, not a masking problem
&lt;/h2&gt;

&lt;p&gt;The obvious approach would be an inpainting mask — edit only the ground, leave the house region untouched pixel-for-pixel. This pipe doesn't do that; it relies entirely on prompt instructions. Every style prompt follows the same shape: describe the new landscaping, then explicitly state what not to touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;modern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Redesign only the outdoor yard and landscaping in a clean modern style: geometric &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;planting beds, ornamental grasses, minimalist concrete or corten-steel planters, a tidy &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paved path, low-maintenance evergreen shrubs and a crisp lawn edge. Keep the house, walls, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;windows, roof, driveway, fences and property boundaries exactly unchanged.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;REAL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;REAL&lt;/code&gt; is a shared suffix appended to every style prompt — a push toward "real DSLR architectural photograph" plus an explicit reject list: no illustration, no cartoon, no Unreal-Engine-style 3D render, no plastic CGI sheen. Landscape imagery lives or dies on "does this look like a real photo of my actual house," so the negative instructions do as much work as the positive description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same finishing-pass trick as the rest of the fal.ai pipes
&lt;/h2&gt;

&lt;p&gt;After the edit model returns a base render, it runs through &lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; again — low &lt;code&gt;creativity&lt;/code&gt; (0.3), moderate &lt;code&gt;resemblance&lt;/code&gt; (0.75) — purely for sharpness, with a &lt;code&gt;try/catch&lt;/code&gt; that falls back to the un-upscaled base on any failure so a paid render is never lost to a flaky second call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;falUpscale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;falQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fal-ai/clarity-upscaler&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upscale_factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;creativity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resemblance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;masterpiece, best quality, highres, sharp, photorealistic garden photograph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submit-then-poll helper (&lt;code&gt;falQueue&lt;/code&gt;) is fully generic — the same function submits to &lt;code&gt;flux-2-pro/edit&lt;/code&gt;, &lt;code&gt;clarity-upscaler&lt;/code&gt;, and backs the text add-on below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upsell: an LLM-written landscaping brief, gated by the same grant
&lt;/h2&gt;

&lt;p&gt;The Pro tier ($19.99, 25 renders across all five styles) unlocks &lt;code&gt;/api/brief&lt;/code&gt;, which calls Claude (&lt;code&gt;claude-haiku-4-5&lt;/code&gt;) to write a plant list, a rough budget, and a contractor-ready scope of work. It reuses the same HMAC grant verification as the image endpoint, plus one extra check — the tier itself has to have &lt;code&gt;brief: true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;grant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown tier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brief&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Design brief is included with the Pro pack only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feature-gating between Starter ($9.99, 2 styles) and Pro ($19.99, 5 styles + brief) is just a boolean flag on a shared &lt;code&gt;TIERS&lt;/code&gt; config, checked in-line at request time — not a separate SKU-handling code path.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-garden-design.pages.dev/" rel="noopener noreferrer"&gt;https://ai-garden-design.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Fusing Two Parents Into One Face: Building an Identity-Preserving AI Baby Generator on Cloudflare Functions</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:22:59 +0000</pubDate>
      <link>https://dev.to/kunstudio/fusing-two-parents-into-one-face-building-an-identity-preserving-ai-baby-generator-on-cloudflare-2pg9</link>
      <guid>https://dev.to/kunstudio/fusing-two-parents-into-one-face-building-an-identity-preserving-ai-baby-generator-on-cloudflare-2pg9</guid>
      <description>&lt;h1&gt;
  
  
  Fusing Two Parents Into One Face: Building an Identity-Preserving AI Baby Generator on Cloudflare Functions
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://ai-baby-generator.pages.dev/" rel="noopener noreferrer"&gt;BabyGlimpse&lt;/a&gt; takes a photo of each parent and generates AI baby portraits across three age stages (newborn, toddler, child). The interesting problem wasn't "call an image model" — it was fusing two separate faces into one coherent identity, inside the wall-clock limits of a serverless function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: PuLID, not a generic text-to-image call
&lt;/h2&gt;

&lt;p&gt;Describing both faces in a text prompt doesn't work — text can't carry facial identity with any fidelity. This pipe uses &lt;code&gt;fal-ai/pulid&lt;/code&gt;, an identity-preserving diffusion model that accepts actual reference images. Both parent photos go in as &lt;code&gt;reference_images&lt;/code&gt;, and &lt;code&gt;id_mix&lt;/code&gt; tells the model to blend the two identities into one new face instead of copying either one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://queue.fal.run/fal-ai/pulid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FAL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;reference_images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refA&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;refB&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;square_hd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;guidance_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;id_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id_mix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fidelity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;negative_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NEG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fal-ai/pulid&lt;/code&gt; runs on SDXL-Lightning, tuned for very few steps (4-8) — more steps don't improve a Lightning checkpoint, they just burn time. &lt;code&gt;id_scale: 0.8&lt;/code&gt; sets how strongly the result must resemble both reference identities versus the text prompt; &lt;code&gt;mode: "fidelity"&lt;/code&gt; biases PuLID toward keeping recognizable features from both parents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finishing pass: a second model call kills the "AI plastic" look
&lt;/h2&gt;

&lt;p&gt;An 8-step Lightning output is identity-accurate but lacks fine skin texture. Rather than push more steps into a model tuned for few steps, the output goes through &lt;code&gt;fal-ai/clarity-upscaler&lt;/code&gt; as a separate pass with a real (non-Lightning) CFG — low &lt;code&gt;creativity&lt;/code&gt;, high &lt;code&gt;resemblance&lt;/code&gt;, so it enhances texture without drifting from the identity PuLID already locked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://queue.fal.run/fal-ai/clarity-upscaler&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FAL_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upscale_factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;creativity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resemblance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;guidance_scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;num_inference_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;negative_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cartoon, cgi, 3d render, plastic skin, waxy skin, ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this pass fails or times out, the code falls back to the un-upscaled base image — a paid job should never come back empty because a secondary enhancement step had a bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the browser drives the loop, not the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fal.run&lt;/code&gt; is an async queue: submit a job, get a &lt;code&gt;status_url&lt;/code&gt;/&lt;code&gt;response_url&lt;/code&gt; pair, poll until &lt;code&gt;status === "COMPLETED"&lt;/code&gt;. A full batch (three age stages, several images each, each with its own upscale pass) can take minutes — too long for one serverless invocation to own.&lt;/p&gt;

&lt;p&gt;So the client owns the top-level loop. Each &lt;code&gt;/api/generate&lt;/code&gt; call handles exactly one age stage; the browser calls it three times in sequence (newborn → toddler → child), updating a progress bar between calls. Each Cloudflare Pages Function invocation only has to survive polling for one stage's images, keeping every request inside the platform's CPU/time budget instead of babysitting one giant multi-minute request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment: a signed grant instead of a database
&lt;/h2&gt;

&lt;p&gt;There's no database here. After PayPal capture succeeds server-side, &lt;code&gt;capture-order.js&lt;/code&gt; issues an HMAC-signed grant — a base64 JSON payload &lt;code&gt;{o, sku, n, exp}&lt;/code&gt; plus a SHA-256 signature via the Web Crypto API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/api/generate&lt;/code&gt; re-derives the same signature and rejects the request if it doesn't match, or if &lt;code&gt;exp&lt;/code&gt; has passed (grants last 6 hours). That's the entire access-control layer for a paid API calling a metered third-party image model — no session store, no order table, just a signed token the browser carries between requests. Pricing itself lives server-side in one &lt;code&gt;TIERS&lt;/code&gt; object; the client never gets to influence what PayPal actually charges.&lt;/p&gt;

&lt;p&gt;Live: &lt;strong&gt;&lt;a href="https://ai-baby-generator.pages.dev/" rel="noopener noreferrer"&gt;https://ai-baby-generator.pages.dev/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why a cancellation-script generator is a lookup table and a template matrix, not an LLM call</title>
      <dc:creator>KunStudio</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:22:26 +0000</pubDate>
      <link>https://dev.to/kunstudio/why-a-cancellation-script-generator-is-a-lookup-table-and-a-template-matrix-not-an-llm-call-16o</link>
      <guid>https://dev.to/kunstudio/why-a-cancellation-script-generator-is-a-lookup-table-and-a-template-matrix-not-an-llm-call-16o</guid>
      <description>&lt;h1&gt;
  
  
  Why a cancellation-script generator is a lookup table and a template matrix, not an LLM call
&lt;/h1&gt;

&lt;p&gt;The obvious way to build "write me a cancellation message" is to send the service name and a goal to a language model and let it generate the paragraph. I built &lt;strong&gt;Cancel Helper&lt;/strong&gt; the other way — a known-service lookup table plus a fixed template matrix — because the moment the output starts citing consumer-protection law, "usually right" stops being good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  A known-service table with fuzzy lookup
&lt;/h2&gt;

&lt;p&gt;Common services (streaming, gyms, software subscriptions) get an entry in a &lt;code&gt;KNOWN&lt;/code&gt; table: which channel actually works for that provider (web form vs. a phone line vs. in-person only), a provider-specific gotcha worth knowing before you start, and — for gym contracts especially — the retention tactic to expect. &lt;code&gt;lookupKnown()&lt;/code&gt; normalizes whatever the user typed (lowercased, &lt;code&gt;+&lt;/code&gt; expanded to "plus", punctuation stripped) and tries an exact slug match first, then falls back to substring/keyword matching, so "Netflix", "netflix premium", and "NETFLIX" all resolve to the same entry without the user having to type the name exactly right. Each known entry also carries a three-tier difficulty rating, shown as a small pip meter, so someone dealing with a gym contract knows upfront it's a harder path than cancelling a streaming subscription in an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template matrix instead of a per-request generation call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;buildScript()&lt;/code&gt; takes three inputs — goal (cancel / refund / cut short a signup trial / reach a human), channel (phone / email / chat / app-store), and tone (plain / cites-the-law) — and assembles one of a fixed set of reviewed paragraphs by string concatenation. Nothing here is generated per request; every combination is a known, previously-read piece of text. The store channel gets a special case regardless of goal: App Store and Google Play subscriptions have to be cancelled in the store itself, because the merchant on the other end of the message has no ability to stop that billing even if they wanted to — a script politely asking a company to cancel a charge it doesn't control would just waste the user's time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the legal-tone branch is the actual reason not to use a model here
&lt;/h2&gt;

&lt;p&gt;The tone toggle's second option cites specific consumer-protection law: the federal Restore Online Shoppers' Confidence Act, the Fair Credit Billing Act for disputed charges, and the FTC's complaint channel. That's exactly the category of claim a generative model will produce fluently and occasionally wrong — a plausible-sounding statute name, a slightly-off requirement, a citation that doesn't exist. A fixed string gets checked once, by a person, and then reused verbatim for every request instead of being reconstructed (and re-risked) every time. The upside of a small, owned piece of text over a model call isn't just consistency — it's that legal-adjacent copy can be kept current on purpose: the tool's own rights summary explicitly notes that the FTC's broader "click to cancel" rule was vacated by a federal court in mid-2025 and a revival attempt was underway as of early 2026, a detail that would already be stale if it were baked into a model's training data instead of a string that gets reviewed when the law changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosing the affiliate step instead of hiding it
&lt;/h2&gt;

&lt;p&gt;Past the script itself, there's a single goal-aware recommendation block — which partner it points to depends on whether the user picked cancel, refund, cut-short-a-trial, or reach-a-human — tagged &lt;code&gt;rel="sponsored"&lt;/code&gt; and carrying a plain disclosure line stated in the block itself rather than buried in a footer: that a signup may earn a commission, that it never changes what the user pays, and that the partner isn't affiliated with the tool. Putting the disclosure in the same block as the pitch, instead of a separate terms page, was the easy part; not routing every goal to the same generic pitch was the part actually worth doing.&lt;/p&gt;

&lt;p&gt;Live tool: &lt;strong&gt;&lt;a href="https://cancel-helper.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink" rel="noopener noreferrer"&gt;https://cancel-helper.pages.dev/?utm_source=devto&amp;amp;utm_medium=backlink&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on the fuzzy-lookup normalization or the goal/channel/tone template matrix if useful — drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
