<?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: Konstantin</title>
    <description>The latest articles on DEV Community by Konstantin (@konstantin_cf7b2929).</description>
    <link>https://dev.to/konstantin_cf7b2929</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3903100%2F61f05a3b-5e4f-4ee6-82f3-e89a360f06f3.png</url>
      <title>DEV Community: Konstantin</title>
      <link>https://dev.to/konstantin_cf7b2929</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/konstantin_cf7b2929"/>
    <language>en</language>
    <item>
      <title>How I built an open-source POS with Telegram, WhatsApp &amp; Viber — one Docker command install</title>
      <dc:creator>Konstantin</dc:creator>
      <pubDate>Wed, 29 Apr 2026 12:42:36 +0000</pubDate>
      <link>https://dev.to/konstantin_cf7b2929/how-i-built-an-open-source-pos-with-telegram-whatsapp-viber-one-docker-command-install-3j25</link>
      <guid>https://dev.to/konstantin_cf7b2929/how-i-built-an-open-source-pos-with-telegram-whatsapp-viber-one-docker-command-install-3j25</guid>
      <description>&lt;p&gt;A friend of mine runs a beauty salon. Every evening she manually texts appointment reminders to her clients. She tracks inventory in Excel. And she pays 20% commission to a booking platform — for clients she spent years building relationships with herself.&lt;br&gt;
So I built Pronto: an open-source POS, CRM, booking system, inventory tracker, and omnichannel notification engine for service businesses. Self-hosted or cloud. Zero commission. One command to install.&lt;br&gt;
This post is about the technical decisions that were actually hard.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture in one paragraph
&lt;/h2&gt;

&lt;p&gt;Next.js 14 API routes + Supabase (PostgreSQL + Auth) + Cloudflare R2 for file storage. Notifications via Resend/SMTP, Telegram Bot API, Meta WhatsApp Cloud API, and Viber Bot API. Docker Compose for self-hosting. PWA via next-pwa for offline POS. Multitenancy via Supabase Row Level Security.&lt;br&gt;
&lt;strong&gt;Credential model:&lt;/strong&gt; each self-hoster (or SaaS tenant) brings their own API keys — Telegram bot token, WhatsApp Phone Number ID + Access Token, Viber token. The platform owner pays nothing to the messenger providers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 1: The double-booking race condition
&lt;/h2&gt;

&lt;p&gt;App-level booking conflict checks have a classic race condition. Two clients simultaneously pick the same slot → both pass the check → both get confirmed.&lt;br&gt;
The fix: a PostgreSQL trigger that runs atomically at commit time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;check_booking_conflict&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;business_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;business_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no_show'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OVERLAPS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'Booking conflict: slot already taken'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&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="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&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;IS NOT DISTINCT FROM&lt;/code&gt; handles the NULL case — when &lt;code&gt;employee_id&lt;/code&gt; is NULL (group bookings), it still works correctly. The API returns HTTP 409 on conflict, and the UI refreshes the slot grid automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Docker broke auth callbacks
&lt;/h2&gt;

&lt;p&gt;The app worked perfectly locally. Inside a Docker container, Supabase auth redirected back to &lt;code&gt;http://0.0.0.0:3000/auth/callback&lt;/code&gt; — because &lt;code&gt;request.url&lt;/code&gt; resolves to the container's internal address, not the real hostname.&lt;br&gt;
The fix: read &lt;code&gt;NEXT_PUBLIC_SITE_URL&lt;/code&gt; explicitly instead of deriving origin from the request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&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;NEXT_PUBLIC_SITE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dashboard`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This made the install truly one-command — no post-install auth debugging required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: WhatsApp's 24-hour window
&lt;/h2&gt;

&lt;p&gt;Nobody documents this clearly upfront: Meta Cloud API only allows free-form text within a 24-hour window after the client initiates contact. Business-initiated messages — appointment reminders, birthday greetings, re-activation nudges — require pre-approved Message Templates (HSM) submitted through Meta Business Manager.&lt;br&gt;
This matters if you're building anything that sends proactive notifications via WhatsApp. You either get your templates approved first, or your reminder system silently fails for business-initiated messages. I documented this honestly in the README as a known limitation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Automatic migrations on startup
&lt;/h2&gt;

&lt;p&gt;Self-hosters shouldn't have to run database migrations manually. I wrote a &lt;code&gt;scripts/migrate.js&lt;/code&gt; that runs all Supabase migrations in order before the app starts, using the &lt;code&gt;pg&lt;/code&gt; library directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/migrate.js&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_completed_successfully&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;service_completed_successfully&lt;/code&gt; condition means the app won't start until all 18 migrations have run cleanly. &lt;code&gt;docker compose up -d&lt;/code&gt; is now genuinely one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js 14 + Tailwind + shadcn/ui&lt;br&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Next.js API routes + Supabase&lt;br&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL (Supabase), 18 migrations&lt;br&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; Supabase Auth — Email + Google OAuth&lt;br&gt;
&lt;strong&gt;Notifications:&lt;/strong&gt; Resend, Telegram Bot API, Meta WhatsApp Cloud API, Viber Bot API&lt;br&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; Cloudflare R2&lt;br&gt;
&lt;strong&gt;Self-hosting:&lt;/strong&gt; Docker Compose, multi-stage build, non-root user&lt;br&gt;
&lt;strong&gt;PWA:&lt;/strong&gt; next-pwa 5.6.0, IndexedDB for offline POS&lt;br&gt;
&lt;strong&gt;i18n:&lt;/strong&gt; next-intl 4.9.0 (EN active, more coming)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's live in v1.0
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;POS&lt;/strong&gt; — completes a sale in 3 clicks, works offline via PWA + IndexedDB&lt;br&gt;
&lt;strong&gt;CRM&lt;/strong&gt; — full client history, visit patterns, birthday, tags, notes&lt;br&gt;
&lt;strong&gt;Booking calendar&lt;/strong&gt; — drag &amp;amp; drop, week view, staff columns&lt;br&gt;
&lt;strong&gt;Public booking page&lt;/strong&gt; — name + phone only, no client account required&lt;br&gt;
&lt;strong&gt;Inventory&lt;/strong&gt; — stock tracking, low-stock alerts via all channels&lt;br&gt;
&lt;strong&gt;Notifications&lt;/strong&gt; — all 4 channels fire automatically: booking confirmed → 24h reminder → 1h reminder → thank-you → 30-day re-activation → birthday → low-stock&lt;br&gt;
&lt;strong&gt;Multitenancy&lt;/strong&gt; — each business isolated via Supabase RLS, own subdomain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running costs at zero customers
&lt;/h2&gt;

&lt;p&gt;~$20–25/month on an existing DigitalOcean server. One Starter customer ($19/mo) covers it. Two Pro customers ($39/mo) puts it in the green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still figuring out
&lt;/h2&gt;

&lt;p&gt;Payment processing as a solo founder based in Georgia (the country) is genuinely difficult. LemonSqueezy and Polar.sh both don't support bank payouts to Georgia. Paddle has been pending verification for two weeks. Currently testing Dodo Payments. If you've solved this from a non-Stripe-supported country, I'd genuinely appreciate knowing what worked.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/SGrappelli/pronto" rel="noopener noreferrer"&gt;github.com/SGrappelli/pronto&lt;/a&gt; — MIT, Docker Compose, CI/CD&lt;br&gt;
Live cloud version: &lt;a href="https://trypronto.app/" rel="noopener noreferrer"&gt;trypronto.app&lt;/a&gt;&lt;br&gt;
Happy to answer questions about any of the technical decisions above.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
