<?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: Nick</title>
    <description>The latest articles on DEV Community by Nick (@radial333).</description>
    <link>https://dev.to/radial333</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%2F4196265%2F6fdbc1ba-2578-4095-b944-e93d84d52949.png</url>
      <title>DEV Community: Nick</title>
      <link>https://dev.to/radial333</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/radial333"/>
    <language>en</language>
    <item>
      <title>Partner commissions on serverless Node and Postgres, with no server to run</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:46:26 +0000</pubDate>
      <link>https://dev.to/radial333/partner-commissions-on-serverless-node-and-postgres-with-no-server-to-run-32og</link>
      <guid>https://dev.to/radial333/partner-commissions-on-serverless-node-and-postgres-with-no-server-to-run-32og</guid>
      <description>&lt;p&gt;I run a small software company in Malaysia. We sell through referral partners and agents, and we pay them a share of what customers pay, not once, but for as long as the customer keeps paying.&lt;/p&gt;

&lt;p&gt;That sounds simple until you try to do it. A customer pays on the 3rd. You promise the partner 25%, but only after a 30-day hold, in case the customer asks for a refund. In month 13 the rate drops to 20%. A refund arrives for half of one payment. The partner quits, and now you owe their unpaid commission to the house. Every one of those is a place where money can be wrong.&lt;/p&gt;

&lt;p&gt;Of course, there are many existing tools in the market that does this out of the box, some even self-hosted. However, most tools that do this want their own server. A hosted CRM, or a self-hosted one with a Docker container, a background worker and a queue. We run on a serverless stack: Next.js functions on Vercel and one Postgres database. There is no machine of ours that stays switched on, and we felt like the cost vs benefit doesn't justify maintaining one just to pay partners.&lt;/p&gt;

&lt;p&gt;Since we could not find a tool that did this and ran as a library inside our stack, we built one. It is called Tandem CRM, it is open source (MIT), and this post is about how it works and what I learned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6sjnkw76kdz8sijj4fs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6sjnkw76kdz8sijj4fs.gif" alt="A 16 second tour of Camp, the admin screen" width="599" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a CRM that is just a library and a schema
&lt;/h2&gt;

&lt;p&gt;Tandem is an npm package plus a set of SQL migrations. You point it at a Postgres database you already have, and it adds a &lt;code&gt;tandem&lt;/code&gt; schema with leads, agents, partners, commissions and payouts. There is no separate server and no container to run.&lt;/p&gt;

&lt;p&gt;That is the part that matters for serverless. Each call is just a function that opens a database connection, does its work inside one transaction, and returns. Nothing holds state in memory between requests, so it does not care how many function instances you have or how often they start and stop. The state lives in Postgres, and Postgres is what keeps two requests from stepping on each other: the code locks the lead's row before it writes, so two payment webhooks arriving at the same moment cannot both win. Hold periods and releases are plain SQL, so a scheduled job (a cron route on Vercel works, and is how we do it) calls one function, &lt;code&gt;release_due_commissions()&lt;/code&gt;, and that is the only background work there is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;tandem-crm
npx tandem-crm init &lt;span class="nt"&gt;--database-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--sample-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;init&lt;/code&gt; applies the migrations, runs a health check, and can seed a small demo workspace so you have something to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every payment gets its own commission line
&lt;/h2&gt;

&lt;p&gt;Here is a lead being created and a payment being recorded. Everything goes through one shared writer, so the rules apply the same way whether the call comes from your app, a webhook or the admin screen.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineTandemConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tandem-crm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createTandemPool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withTandemSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createLead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recordPayment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tandem-crm/db&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;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTandemPool&lt;/span&gt;&lt;span class="p"&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;DATABASE_URL&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineTandemConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;commission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;holdDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rateSchedule&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="na"&gt;fromMonth&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="na"&gt;basisPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// 25% for the first year&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fromMonth&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="na"&gt;basisPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// 20% after that&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;leadId&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="nf"&gt;withTandemSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;createLead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Aman Logistics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;contactPhone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+60 12-345 6789&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;qualificationMetric&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;productTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;partnerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rajesh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sourceEventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup-1042&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTandemSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;recordPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;workspaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;amountMinor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// RM300.00, always whole sen&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MYR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;confirmedAt&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="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;partnerStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;active&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="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;Each payment produces one commission line with its own rate and its own 30-day hold. A scheduled job releases lines once they are due. If a refund comes in, the matching line is reduced or clawed back. If a partner is deactivated, their unpaid lines move to a house account.  That policy is something you configure, not something we decide for you. We just decided to set ours up this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why events
&lt;/h2&gt;

&lt;p&gt;Tandem stores what happened, not just the current state. A lead is a stream of events: created, qualified, converted, paid, refunded. The current status is calculated by replaying them.&lt;/p&gt;

&lt;p&gt;The practical benefit is that every decision is saved in the event it was made in. The rate that applied, the qualification result, who the beneficiary was. If you change your commission settings next year, old payments do not quietly change. And when a partner asks "why did I get this amount?", you can answer with the actual history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Row-level security, so one tenant cannot read another
&lt;/h2&gt;

&lt;p&gt;Tandem uses Postgres row-level security. Every query runs as a role called &lt;code&gt;authenticated&lt;/code&gt;, and every row belongs to a workspace. Agents can only append three kinds of event (&lt;code&gt;lead.lost&lt;/code&gt;, &lt;code&gt;lead.stage_changed&lt;/code&gt;, &lt;code&gt;conversion.confirmed&lt;/code&gt;). Anything that moves money needs an owner or admin, and the database enforces it, not just the application code.&lt;/p&gt;

&lt;p&gt;The one setup step people trip on: the role you connect with must be able to &lt;code&gt;grant authenticated&lt;/code&gt;. &lt;code&gt;init&lt;/code&gt; explains this clearly if it cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What two audits found
&lt;/h2&gt;

&lt;p&gt;I am not a programmer by training, and I built this with quite a bit of help from AI and learning each step of the way (I make sure I understand at least fundamentally what the code is supposed to do). That is exactly why I did not want to trust it on my own. I had two independent audits run over the money code, and I wrote tests against a real Postgres instead of mocks.&lt;/p&gt;

&lt;p&gt;The audits found real problems. A scheduled release job that could crash part way through. A case where a payment could silently produce no commission at all. Writes that were not safe if two requests arrived at the same moment. All of them are fixed, and each fix has a test that fails without it.&lt;/p&gt;

&lt;p&gt;The rules we now follow for anything that touches money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Money is integer minor units. No floating point anywhere.&lt;/li&gt;
&lt;li&gt;Every write to a lead locks the lead row first, replays the history plus the new events, and only then inserts.&lt;/li&gt;
&lt;li&gt;Retrying a request with the same source id returns the existing result and never creates a duplicate.&lt;/li&gt;
&lt;li&gt;A fix needs a test that fails without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it is tested
&lt;/h2&gt;

&lt;p&gt;The test suites run against real Postgres 14, 15, 16, 17 and 18 in CI, on Node 20 and 22, and also as a limited, non-superuser admin account, which is what most managed hosts give you. I have also run it by hand on Neon, Supabase, Layerbase and Aiven. Aiven needs its own certificate file, and the docs say how.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not (yet)
&lt;/h2&gt;

&lt;p&gt;It is early and pre-1.0, and it has not been proven at scale. It has run the partner commission ledger for our own product, Utuh, since 20 October 2026, and I checked it end to end on the live Stripe account, with a real card payment from an outside account and a real refund. No outside developers use the package yet, and we still pay partners by hand, by bank transfer. It is one Postgres and an append-only events table, which should be fine for small and medium use, but I have not load tested it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell me what breaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/angkutdigital/tandem-crm" rel="noopener noreferrer"&gt;https://github.com/angkutdigital/tandem-crm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://tandemcrm.dev" rel="noopener noreferrer"&gt;https://tandemcrm.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most useful thing you can do is run it against your own Postgres host and open a "Postgres host report" issue, whether it worked or not. If a guide confused you, that is a bug in the guide, and I would like to know.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>node</category>
      <category>typescript</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
