Small calculators are a great way to practice writing correct, testable code. They have clear inputs, deterministic outputs and a surprising number of edge cases. In this tutorial we will build a wagering requirement calculator: the kind of tool that tells a user how much turnover a promotional bonus requires, what it is expected to cost and how many bets it takes.
The domain is gambling promotions, so a quick note up front: this is a tutorial about code and arithmetic, not an endorsement of any operator. The goal is a tool that tells users the truth about a number that is often presented misleadingly. A production version of this idea runs on bahisbonusu.com, a Turkish-language reference site, and you can compare your output against its bonus çevrim calculator.
We will use no framework: plain ES modules, Node's built-in test runner and a few lines of HTML.
The requirements
Our calculator takes:
- deposit amount
- bonus amount
- wagering multiplier
- base:
"bonus"or"deposit+bonus" - game contribution percentage
- RTP percentage (to derive house edge)
- planned stake per bet and max allowed stake
- already wagered amount (optional)
- validity in days (optional)
And returns:
- total required turnover
- effective stake needed after contribution
- remaining stake
- expected loss and bonus expected value
- number of bets and bets per day
- warnings
Project layout
wagering-calc/
package.json
src/
money.js
calculate.js
test/
calculate.test.js
index.html
{
"name": "wagering-calc",
"type": "module",
"scripts": { "test": "node --test" }
}
Step 1: working in minor units
Floating-point arithmetic will eventually bite a money calculation. 0.1 + 0.2 is not 0.3. The simplest defense is to convert to integer minor units (cents or kuruş) on input and back on output.
// src/money.js
export const toMinor = (value) => {
const n = Number(value);
if (!Number.isFinite(n)) throw new TypeError(`Invalid amount: ${value}`);
return Math.round(n * 100);
};
export const fromMinor = (minor) => minor / 100;
export const formatMoney = (minor, locale = "en-US", currency = "USD") =>
new Intl.NumberFormat(locale, { style: "currency", currency })
.format(fromMinor(minor));
Step 2: input validation
Validate once, at the boundary, and return a normalized object. That keeps the calculation function clean.
// src/calculate.js
import { toMinor } from "./money.js";
const BASES = new Set(["bonus", "deposit+bonus"]);
export function normalize(input) {
const errors = [];
const num = (key, { min = 0, max = Infinity, required = true } = {}) => {
const raw = input[key];
if (raw === undefined || raw === "") {
if (required) errors.push(`${key} is required`);
return undefined;
}
const n = Number(raw);
if (!Number.isFinite(n) || n < min || n > max) {
errors.push(`${key} must be between ${min} and ${max}`);
}
return n;
};
const out = {
deposit: toMinor(num("deposit") ?? 0),
bonus: toMinor(num("bonus") ?? 0),
multiplier: num("multiplier", { min: 0, max: 200 }),
contribution: num("contribution", { min: 0, max: 100 }) / 100,
rtp: num("rtp", { min: 50, max: 100 }) / 100,
stake: toMinor(num("stake", { min: 0.01 }) ?? 0),
maxStake: input.maxStake ? toMinor(num("maxStake", { min: 0.01 })) : null,
wagered: toMinor(num("wagered", { required: false }) ?? 0),
days: num("days", { min: 1, max: 365, required: false }) ?? null,
base: input.base,
};
if (!BASES.has(out.base)) errors.push(`base must be one of ${[...BASES]}`);
return { value: out, errors };
}
Notice that base has no default. Silently assuming "bonus only" is the single most common bug in calculators of this kind, because applying the multiplier to deposit plus bonus doubles the requirement.
Step 3: the calculation
// src/calculate.js (continued)
export function calculate(input) {
const { value: v, errors } = normalize(input);
if (errors.length) return { ok: false, errors };
const warnings = [];
const baseAmount = v.base === "bonus" ? v.bonus : v.deposit + v.bonus;
const required = Math.ceil(baseAmount * v.multiplier);
if (v.contribution === 0) {
return {
ok: false,
errors: ["This game contributes 0%, so the requirement cannot be cleared with it."],
};
}
const effective = Math.ceil(required / v.contribution);
const cleared = Math.floor(v.wagered * v.contribution);
const remainingRequired = Math.max(0, required - cleared);
const remainingStake = Math.ceil(remainingRequired / v.contribution);
const houseEdge = 1 - v.rtp;
const expectedLoss = Math.round(remainingStake * houseEdge);
const bonusEV = v.bonus - Math.round(effective * houseEdge);
const bets = Math.ceil(remainingStake / v.stake);
const betsPerDay = v.days ? Math.ceil(bets / v.days) : null;
if (v.maxStake !== null && v.stake > v.maxStake) {
warnings.push("Planned stake exceeds the max bet rule. This can void the bonus.");
}
if (bonusEV < 0) {
warnings.push("Expected cost of clearing exceeds the bonus value.");
}
if (betsPerDay !== null && betsPerDay > 300) {
warnings.push("This requires a very high number of bets per day.");
}
const breakEvenMultiplier =
houseEdge > 0 && v.base === "bonus" ? v.contribution / houseEdge : null;
return {
ok: true,
required,
effective,
remainingStake,
expectedLoss,
bonusEV,
bets,
betsPerDay,
breakEvenMultiplier,
warnings,
};
}
A few design choices worth calling out:
-
Rounding direction. Required amounts and bet counts are rounded up with
Math.ceil. Cleared progress is rounded down. The tool should never tell a user they are closer to done than they are. -
Zero contribution is an error, not
Infinity. - Warnings are data. The function returns them; the UI decides how to show them. That keeps the logic testable.
-
Break-even multiplier only makes sense for the bonus-only base, so we return
nullotherwise rather than a misleading number.
Step 4: tests with node:test
Node 18+ ships a test runner, so no dependencies are needed.
// test/calculate.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { calculate } from "../src/calculate.js";
const base = {
deposit: 100, bonus: 100, multiplier: 30, base: "bonus",
contribution: 100, rtp: 96, stake: 2,
};
test("bonus-only turnover", () => {
const r = calculate(base);
assert.equal(r.required, 300000); // 3,000.00 in minor units
assert.equal(r.bets, 1500);
});
test("deposit+bonus doubles turnover", () => {
const r = calculate({ ...base, base: "deposit+bonus" });
assert.equal(r.required, 600000);
});
test("contribution scales effective stake", () => {
const r = calculate({ ...base, contribution: 10 });
assert.equal(r.effective, 3000000);
});
test("zero contribution is an error", () => {
const r = calculate({ ...base, contribution: 0 });
assert.equal(r.ok, false);
});
test("negative EV produces a warning", () => {
const r = calculate(base); // 3,000 * 4% = 120 > 100
assert.equal(r.bonusEV, -2000);
assert.ok(r.warnings.some((w) => w.includes("Expected cost")));
});
test("max stake violation warns", () => {
const r = calculate({ ...base, stake: 10, maxStake: 5 });
assert.ok(r.warnings.some((w) => w.includes("max bet")));
});
test("partial progress reduces remaining stake", () => {
const r = calculate({ ...base, wagered: 1000 });
assert.equal(r.remainingStake, 200000);
});
test("missing base is rejected", () => {
const { base: _, ...rest } = base;
const r = calculate(rest);
assert.equal(r.ok, false);
});
test("break-even multiplier", () => {
const r = calculate(base);
assert.equal(Math.round(r.breakEvenMultiplier), 25);
});
Run with npm test. Every edge case we reasoned about now has a test pinning it down. If you later refactor to TypeScript or move the logic into a web worker, these tests travel with you.
Step 5: a minimal UI sketch
The UI should be boring on purpose: labeled inputs, a results panel, warnings shown prominently and a responsible-play notice that is always visible.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Wagering calculator</title>
<form id="calc">
<label>Deposit <input name="deposit" type="number" step="0.01" required></label>
<label>Bonus <input name="bonus" type="number" step="0.01" required></label>
<label>Multiplier <input name="multiplier" type="number" required></label>
<fieldset>
<legend>Multiplier applies to</legend>
<label><input type="radio" name="base" value="bonus"> Bonus only</label>
<label><input type="radio" name="base" value="deposit+bonus"> Deposit + bonus</label>
</fieldset>
<label>Contribution % <input name="contribution" type="number" value="100"></label>
<label>RTP % <input name="rtp" type="number" value="96"></label>
<label>Stake per bet <input name="stake" type="number" step="0.01" required></label>
<label>Max stake <input name="maxStake" type="number" step="0.01"></label>
<label>Days <input name="days" type="number"></label>
<button>Calculate</button>
</form>
<output id="result" aria-live="polite"></output>
<p><small>18+ only. This tool shows expected costs; it is not advice to gamble.</small></p>
<script type="module" src="./ui.js"></script>
</html>
// ui.js
import { calculate } from "./src/calculate.js";
import { formatMoney } from "./src/money.js";
const form = document.querySelector("#calc");
const out = document.querySelector("#result");
form.addEventListener("submit", (e) => {
e.preventDefault();
const r = calculate(Object.fromEntries(new FormData(form)));
if (!r.ok) {
out.textContent = r.errors.join(" ");
return;
}
out.innerHTML = "";
const lines = [
`Required turnover: ${formatMoney(r.required)}`,
`Stake still needed: ${formatMoney(r.remainingStake)}`,
`Expected cost: ${formatMoney(r.expectedLoss)}`,
`Bonus expected value: ${formatMoney(r.bonusEV)}`,
`Bets needed: ${r.bets}` + (r.betsPerDay ? ` (${r.betsPerDay}/day)` : ""),
...r.warnings.map((w) => `Warning: ${w}`),
];
for (const line of lines) {
const p = document.createElement("p");
p.textContent = line;
out.append(p);
}
});
Using textContent instead of string-built HTML avoids injection issues, and aria-live makes results announce themselves to screen readers.
Where to go next
- Monte Carlo mode. Simulate a few thousand sessions with a volatility parameter to show the probability of completing the requirement before the balance runs out.
- Sports mode. Add minimum-odds rules and estimate the bookmaker margin from market odds.
-
Localization. Use
Intl.NumberFormatwithtr-TRandTRYfor Turkish users; the logic does not change. - Shareable URLs. Serialize inputs into query parameters so a calculation can be bookmarked.
Conclusion
A wagering calculator is a small project, but doing it well forces good habits: integer money, validation at the boundary, explicit defaults, honest rounding and tests for each edge case. The most important feature is not technical at all: showing the expected cost next to the headline number. For a reference implementation in Turkish, see the çevrim hesaplama tool and the rest of bahisbonusu.com.
Top comments (0)