diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 687316e4265e2dc6d710911007fd767184d3b14b..46a495ffdc06ab2517f3d69aecd9f3724bbc383b 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -564,6 +564,41 @@ class RistrettoRedeemer(object):
         return passes
 
 
+def token_count_for_group(num_groups, total_tokens, group_number):
+    """
+    Determine a number of tokens to retrieve for a particular group out of an
+    overall redemption attempt.
+
+    :param int num_groups: The total number of groups the tokens will be
+        divided into.
+
+    :param int total_tokens: The total number of tokens to divide up.
+
+    :param int group_number: The particular group for which to determine a
+        token count.
+
+    :return int: A number of tokens to redeem in this group.
+    """
+    if total_tokens < num_groups:
+        raise ValueError(
+            "Cannot distribute {} tokens among {} groups coherently.".format(
+                total_tokens,
+                num_groups,
+            ),
+        )
+    if group_number >= num_groups or group_number < 0:
+        raise ValueError(
+            "Group number {} is out of valid range [0..{})".format(
+                group_number,
+                num_groups,
+            ),
+        )
+    group_size, remainder = divmod(total_tokens, num_groups)
+    if group_number < remainder:
+        return group_size + 1
+    return group_size
+
+
 @attr.s
 class PaymentController(object):
     """
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 7776ff739b3ec50b88b37f8ecf1a307a7eba1318..3baf11475b7daa296d8ef621ba493175238a3a45 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -359,6 +359,19 @@ def voucher_counters():
     )
 
 
+def redemption_group_counts():
+    """
+    Build integers which can represent the number of groups in the redemption
+    process.
+    """
+    return integers(
+        min_value=1,
+        # Make this similar to the max_value of voucher_counters since those
+        # counters count through the groups.
+        max_value=256,
+    )
+
+
 def byte_strings(label, length, entropy):
     """
     Build byte strings of the given length with at most the given amount of
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index befe9fcf6e623722a9af3234d2bc0772b372e1f3..9c618973a3ae0ca633e8b4416cc1dda25ab2e19b 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -18,6 +18,7 @@ Tests for ``_zkapauthorizer.controller``.
 
 from __future__ import (
     absolute_import,
+    division,
 )
 
 from json import (
@@ -56,6 +57,7 @@ from testtools.twistedsupport import (
 
 from hypothesis import (
     given,
+    assume,
 )
 from hypothesis.strategies import (
     integers,
@@ -107,6 +109,7 @@ from ..controller import (
     PaymentController,
     AlreadySpent,
     Unpaid,
+    token_count_for_group,
 )
 
 from ..model import (
@@ -123,16 +126,86 @@ from .strategies import (
     vouchers,
     voucher_objects,
     voucher_counters,
+    redemption_group_counts,
     dummy_ristretto_keys,
     clocks,
 )
 from .matchers import (
     Provides,
+    raises,
+    between,
 )
 from .fixtures import (
     TemporaryVoucherStore,
 )
 
+
+class TokenCountForGroupTests(TestCase):
+    """
+    Tests for ``token_count_for_group``.
+    """
+    @given(
+        integers(),
+        integers(),
+        integers(),
+    )
+    def test_out_of_bounds(self, num_groups, total_tokens, group_number):
+        """
+        If there are not enough tokens so that each group gets at least one or if
+        the indicated group number does properly identify a group from the
+        range then ``ValueError`` is raised.
+        """
+        assume(
+            group_number < 0 or
+            group_number >= num_groups or
+            total_tokens < num_groups
+        )
+        self.assertThat(
+            lambda: token_count_for_group(num_groups, total_tokens, group_number),
+            raises(ValueError),
+        )
+
+    @given(
+        redemption_group_counts(),
+        integers(min_value=1),
+    )
+    def test_sum(self, num_groups, total_tokens):
+        """
+        The sum of the token count for all groups equals the requested total
+        tokens.
+        """
+        assume(total_tokens >= num_groups)
+        self.assertThat(
+            sum(
+                token_count_for_group(num_groups, total_tokens, group_number)
+                for group_number
+                in range(num_groups)
+            ),
+            Equals(total_tokens),
+        )
+
+    @given(
+        redemption_group_counts(),
+        integers(min_value=1),
+    )
+    def test_well_distributed(self, num_groups, total_tokens):
+        """
+        Tokens are distributed roughly evenly across all group numbers.
+        """
+        assume(total_tokens >= num_groups)
+
+        lower_bound = total_tokens // num_groups
+        upper_bound = total_tokens // num_groups + 1
+
+        self.assertThat(
+            list(
+                token_count_for_group(num_groups, total_tokens, group_number)
+                for group_number
+                in range(num_groups)
+            ),
+            AllMatch(between(lower_bound, upper_bound)),
+        )
+
 class PaymentControllerTests(TestCase):
     """
     Tests for ``PaymentController``.