diff --git a/docs/source/interface.rst b/docs/source/interface.rst
index 5231e7265a355117ccd1bc0af68084ba1ef6b66e..5911d12a30ecbf19324d0cdeee9696a77c27fd7e 100644
--- a/docs/source/interface.rst
+++ b/docs/source/interface.rst
@@ -137,7 +137,7 @@ The response is **OK** with ``application/json`` content-type response body like
 
 The elements of the list are objects like the one returned by issuing a **GET** to a child of this collection resource.
 
-``GET /storage-plugins/privatestorageio/zkapauthz-v1/unblinded-token``
+``GET /storage-plugins/privatestorageio-zkapauthz-v1/unblinded-token``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 This endpoint allows an external agent to retrieve unused unblinded tokens present in the node's database.
@@ -165,7 +165,7 @@ If it has run,
  * ``when``: associated with an ISO8601 datetime string giving the approximate time the process ran
  * ``count``: associated with a number giving the number of passes which would need to be spent to renew leases on all stored objects seen during the lease maintenance activity
 
-``POST /storage-plugins/privatestorageio/zkapauthz-v1/unblinded-token``
+``POST /storage-plugins/privatestorageio-zkapauthz-v1/unblinded-token``
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 This endpoint allows an external agent to insert new unblinded tokens into the node's database.
@@ -180,3 +180,34 @@ The request body must be ``application/json`` encoded and contain an object like
 The response is **OK** with ``application/json`` content-type response body like::
 
   { }
+
+``POST /storage-plugins/privatestorageio-zkapauthz-v1/calculate-price``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This endpoint allows an agent to calculate the number of ZKAPs it will cost to store a collection of files of specified sizes.
+This is intended as the basis for tools which aid in user understanding of the cost of their actions.
+
+The request body must be ``application/json`` encoded and contain an object like::
+
+  { "version": 1
+  , "sizes: [ <integer>, ... ]
+  }
+
+The ``version`` property must currently be **1**.
+The ``sizes`` property is a list of integers giving file sizes in bytes.
+
+The response is **OK** with ``application/json`` content-type response body like::
+
+  { "price": <integer>, "period": <integer> }
+
+The ``price`` property gives the number of ZKAPs which would have to be spent to store files of the given sizes.
+The ``period`` property gives the number of seconds those files would be stored by spending that number of ZKAPs.
+
+The price obtained this way is valid in two scenarios.
+First,
+the case where none of the files have been uploaded yet.
+In this case uploading the files and storing them for **period** seconds will cost **price** ZKAPs.
+Second,
+the case where the files have already been uploaded but their leases need to be renewed.
+In this case, renewing the leases so they last until **period** seconds after the current time will cost **price** ZKAPs.
+Note that in this case any lease time currently remaining on any files has no bearing on the calculated price.
diff --git a/setup.cfg b/setup.cfg
index dfc4960747ba2d57bea9b12deff72f59371cd743..42dd1649cf7c1b05292f9e6f17647bec69143f59 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -42,6 +42,7 @@ install_requires =
     Twisted[tls,conch]>=18.4.0
     tahoe-lafs==1.14.0
     treq
+    pyutil
 
 [versioneer]
 VCS = git
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 7dc5146ddd15a11a8837a8b9af7b928069bc58b7..96d70747cc45f31cebd2c67cc3bcd821c3977e54 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -458,7 +458,9 @@ class IssuerConfigurationMismatch(Exception):
     ZKAPs will not be usable and new ones must be obtained.
     """
     def __str__(self):
-        return "Announced issuer ({}) disagrees with configured issuer ({}).".format(self.args)
+        return "Announced issuer ({}) disagrees with configured issuer ({}).".format(
+            *self.args
+        )
 
 
 @implementer(IRedeemer)
diff --git a/src/_zkapauthorizer/pricecalculator.py b/src/_zkapauthorizer/pricecalculator.py
new file mode 100644
index 0000000000000000000000000000000000000000..007ec9cdf212839ddabbac9555308b29fb158483
--- /dev/null
+++ b/src/_zkapauthorizer/pricecalculator.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Calculate the price, in ZKAPs, for storing files.
+
+The underlying storage system operates only on individual shares.  Thus, it
+*does not* use this file-oriented calculator.  However, for end-users,
+file-oriented pricing is often more helpful.  This calculator builds on the
+share-oriented price calculation to present file-oriented price information.
+
+It accounts for erasure encoding data expansion.  It does not account for the
+real state of the storage system (e.g., if some data is *already* stored then
+storing it "again" is essentially free but this will not be reflected by this
+calculator).
+"""
+
+import attr
+
+from .storage_common import (
+    required_passes,
+    share_size_for_data,
+)
+
+@attr.s
+class PriceCalculator(object):
+    """
+    :ivar int _shares_needed: The number of shares which are required to
+        reconstruct the original data.
+
+    :ivar int _shares_total: The total number of shares which will be
+        produced in the erasure encoding process.
+
+    :ivar int _pass_value: The bytes component of the bytes×time value of a
+        single pass.
+    """
+    _shares_needed = attr.ib()
+    _shares_total = attr.ib()
+    _pass_value = attr.ib()
+
+    def calculate(self, sizes):
+        """
+        Calculate the price to store data of the given sizes for one lease
+        period.
+
+        :param [int] sizes: The sizes of the individual data items in bytes.
+
+        :return int: The number of ZKAPs required.
+        """
+        share_sizes = (
+            share_size_for_data(self._shares_needed, size)
+            for size
+            in sizes
+        )
+        all_required_passes = (
+            required_passes(self._pass_value, [share_size] * self._shares_total)
+            for share_size
+            in share_sizes
+        )
+        price = sum(all_required_passes, 0)
+        return price
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index 10b6907fc0b94135617c7812957f1efcbf0e555d..96dc29dbaeeda4ed9ee2f04150d5651c85380af4 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 # Copyright 2019 PrivateStorage.io, LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -40,6 +41,9 @@ from twisted.logger import (
 from twisted.web.http import (
     BAD_REQUEST,
 )
+from twisted.web.server import (
+    NOT_DONE_YET,
+)
 from twisted.web.resource import (
     IResource,
     ErrorPage,
@@ -55,6 +59,17 @@ from ._base64 import (
     urlsafe_b64decode,
 )
 
+from .storage_common import (
+    get_configured_shares_needed,
+    get_configured_shares_total,
+    get_configured_pass_value,
+    get_configured_lease_duration,
+)
+
+from .pricecalculator import (
+    PriceCalculator,
+)
+
 from .controller import (
     PaymentController,
     get_redeemer,
@@ -106,16 +121,35 @@ def from_configuration(node_config, store, redeemer=None, default_token_count=No
     if default_token_count is None:
         default_token_count = NUM_TOKENS
     controller = PaymentController(store, redeemer, default_token_count)
+
+    calculator = PriceCalculator(
+        get_configured_shares_needed(node_config),
+        get_configured_shares_total(node_config),
+        get_configured_pass_value(node_config),
+    )
+    calculate_price = _CalculatePrice(
+        calculator,
+        get_configured_lease_duration(node_config),
+    )
+
     root = create_private_tree(
         lambda: node_config.get_private_config(b"api_auth_token"),
-        authorizationless_resource_tree(store, controller),
+        authorizationless_resource_tree(
+            store,
+            controller,
+            calculate_price,
+        ),
     )
     root.store = store
     root.controller = controller
     return root
 
 
-def authorizationless_resource_tree(store, controller):
+def authorizationless_resource_tree(
+        store,
+        controller,
+        calculate_price,
+):
     """
     Create the full ZKAPAuthorizer client plugin resource hierarchy with no
     authorization applied.
@@ -123,6 +157,8 @@ def authorizationless_resource_tree(store, controller):
     :param VoucherStore store: The store to use.
     :param PaymentController controller: The payment controller to use.
 
+    :param IResource calculate_price: The resource for the price calculation endpoint.
+
     :return IResource: The root of the resource hierarchy.
     """
     root = Resource()
@@ -144,9 +180,108 @@ def authorizationless_resource_tree(store, controller):
         b"version",
         _ProjectVersion(),
     )
+    root.putChild(
+        b"calculate-price",
+        calculate_price,
+    )
     return root
 
 
+class _CalculatePrice(Resource):
+    """
+    This resource exposes a storage price calculator.
+    """
+    allowedMethods = [b"POST"]
+
+    render_HEAD = render_GET = None
+
+    def __init__(self, price_calculator, lease_period):
+        """
+        :param _PriceCalculator price_calculator: The object which can actually
+            calculate storage prices.
+
+        :param lease_period: See ``authorizationless_resource_tree``
+        """
+        self._price_calculator = price_calculator
+        self._lease_period = lease_period
+        Resource.__init__(self)
+
+    def render_POST(self, request):
+        """
+        Calculate the price in ZKAPs to store or continue storing files specified
+        sizes.
+        """
+        if wrong_content_type(request, u"application/json"):
+            return NOT_DONE_YET
+
+        application_json(request)
+        payload = request.content.read()
+        try:
+            body_object = loads(payload)
+        except ValueError:
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "could not parse request body",
+            })
+
+        try:
+            version = body_object[u"version"]
+            sizes = body_object[u"sizes"]
+        except (TypeError, KeyError):
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "could not read `version` and `sizes` properties",
+            })
+
+        if version != 1:
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "did not find required version number 1 in request",
+            })
+
+        if (not isinstance(sizes, list) or
+            not all(
+                isinstance(size, (int, long)) and size >= 0
+                for size
+                in sizes
+        )):
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "did not find required positive integer sizes list in request",
+            })
+
+        application_json(request)
+
+        price = self._price_calculator.calculate(sizes)
+        return dumps({
+            u"price": price,
+            u"period": self._lease_period,
+        })
+
+
+def wrong_content_type(request, required_type):
+    """
+    Check the content-type of a request and respond if it is incorrect.
+
+    :param request: The request object to check.
+
+    :param unicode required_type: The required content-type (eg
+        ``u"application/json"``).
+
+    :return bool: ``True`` if the content-type is wrong and an error response
+        has been generated.  ``False`` otherwise.
+    """
+    actual_type = request.requestHeaders.getRawHeaders(
+        u"content-type",
+        [None],
+    )[0]
+    if actual_type != required_type:
+        request.setResponseCode(BAD_REQUEST)
+        request.finish()
+        return True
+    return False
+
+
 def application_json(request):
     """
     Set the given request's response content-type to ``application/json``.
diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py
index 1f429dd50a924557d73950c458f071bd4e9f9721..dd9a9f49e15bd542aa70cbbbdaadedfde5c64bbb 100644
--- a/src/_zkapauthorizer/storage_common.py
+++ b/src/_zkapauthorizer/storage_common.py
@@ -34,6 +34,10 @@ from .eliot import (
     MUTABLE_PASSES_REQUIRED,
 )
 
+from pyutil.mathutil import (
+    div_ceil,
+)
+
 @attr.s(frozen=True)
 class MorePassesRequired(Exception):
     """
@@ -73,6 +77,34 @@ slot_testv_and_readv_and_writev_message = _message_maker(u"slot_testv_and_readv_
 # submitted.
 BYTES_PER_PASS = 1024 * 1024
 
+def get_configured_shares_needed(node_config):
+    """
+    Determine the configured-specified value of "needed" shares (``k``).
+
+    If no value is explicitly configured, the Tahoe-LAFS default (as best as
+    we know it) is returned.
+    """
+    return int(node_config.get_config(
+        section=u"client",
+        option=u"shares.needed",
+        default=3,
+    ))
+
+
+def get_configured_shares_total(node_config):
+    """
+    Determine the configured-specified value of "total" shares (``N``).
+
+    If no value is explicitly configured, the Tahoe-LAFS default (as best as
+    we know it) is returned.
+    """
+    return int(node_config.get_config(
+        section=u"client",
+        option=u"shares.total",
+        default=10,
+    ))
+
+
 def get_configured_pass_value(node_config):
     """
     Determine the configuration-specified value of a single ZKAP.
@@ -88,10 +120,22 @@ def get_configured_pass_value(node_config):
         default=BYTES_PER_PASS,
     ))
 
+
+def get_configured_lease_duration(node_config):
+    """
+    Just kidding.  Lease duration is hard-coded.
+
+    :return int: The number of seconds after which a newly acquired lease will
+        be valid.
+    """
+    # See lots of places in Tahoe-LAFS, eg src/allmydata/storage/server.py
+    return 31 * 24 * 60 * 60
+
+
 def required_passes(bytes_per_pass, share_sizes):
     """
-    Calculate the number of passes that are required to store ``stored_bytes``
-    for one lease period.
+    Calculate the number of passes that are required to store shares of the
+    given sizes for one lease period.
 
     :param int bytes_per_pass: The number of bytes the storage of which for
         one lease period one pass covers.
@@ -114,6 +158,23 @@ def required_passes(bytes_per_pass, share_sizes):
     return result
 
 
+def share_size_for_data(shares_needed, datasize):
+    """
+    Calculate the size of a single erasure encoding share for data of the
+    given size and with the given level of redundancy.
+
+    :param int shares_needed: The number of shares (``k``) from the erasure
+        encoding process which are required to reconstruct original data of
+        the indicated size.
+
+    :param int datasize: The size of the data to consider, in bytes.
+
+    :return int: The size of a single erasure encoding share for the given
+        inputs.
+    """
+    return div_ceil(datasize, shares_needed)
+
+
 def has_writes(tw_vectors):
     """
     :param tw_vectors: See
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 025260d8be245dd8c703ed30429a12c20cb6ba24..4384cacb8d946d8408cd3e4f5e3bb8f9f8f835a9 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -122,10 +122,36 @@ def _config_string_from_sections(divided_sections):
     ))
 
 
-def tahoe_config_texts(storage_client_plugins):
+def tahoe_config_texts(storage_client_plugins, shares):
     """
     Build the text of complete Tahoe-LAFS configurations for a node.
+
+    :param storage_client_plugins: A dictionary with storage client plugin
+        names as keys.
+
+    :param shares: A strategy to build erasure encoding parameters.  These are
+        built as a three-tuple giving (needed, total, happy).  Each element
+        may be an integer or None to leave it unconfigured (and rely on the
+        default).
     """
+    def merge_shares(shares, the_rest):
+        for (k, v) in zip(("needed", "happy", "total"), shares):
+            if v is not None:
+                the_rest["shares." + k] = u"{}".format(v)
+        return the_rest
+
+    client_section = builds(
+        merge_shares,
+        shares,
+        fixed_dictionaries(
+            {
+                "storage.plugins": just(
+                    u",".join(storage_client_plugins.keys()),
+                ),
+            },
+        ),
+    )
+
     return builds(
         lambda *sections: _config_string_from_sections(
             sections,
@@ -144,26 +170,23 @@ def tahoe_config_texts(storage_client_plugins):
                         "nickname": node_nicknames(),
                     },
                 ),
-                "client": fixed_dictionaries(
-                    {
-                        "storage.plugins": just(
-                            u",".join(storage_client_plugins.keys()),
-                        ),
-                    },
-                ),
+                "client": client_section,
             },
         ),
     )
 
 
-def minimal_tahoe_configs(storage_client_plugins=None):
+def minimal_tahoe_configs(storage_client_plugins=None, shares=just((None, None, None))):
     """
     Build complete Tahoe-LAFS configurations for a node.
+
+    :param shares: See ``tahoe_config_texts``.
     """
     if storage_client_plugins is None:
         storage_client_plugins = {}
     return tahoe_config_texts(
         storage_client_plugins,
+        shares,
     ).map(
         lambda config_text: lambda basedir, portnumfile: config_from_string(
             basedir,
@@ -287,14 +310,33 @@ def client_errorredeemer_configurations(details):
     })
 
 
-def tahoe_configs(zkapauthz_v1_configuration=client_dummyredeemer_configurations()):
+def tahoe_configs(
+        zkapauthz_v1_configuration=client_dummyredeemer_configurations(),
+        shares=just((None, None, None)),
+):
     """
     Build complete Tahoe-LAFS configurations including the zkapauthorizer
     client plugin section.
+
+    :param shares: See ``tahoe_config_texts``.
     """
     return minimal_tahoe_configs({
         u"privatestorageio-zkapauthz-v1": zkapauthz_v1_configuration,
-    })
+    }, shares)
+
+
+def share_parameters():
+    """
+    Build three-tuples of integers giving usable k, happy, N parameters to
+    Tahoe-LAFS' erasure encoding process.
+    """
+    return lists(
+        integers(min_value=1, max_value=255),
+        min_size=3,
+        max_size=3,
+    ).map(
+        sorted,
+    )
 
 
 def vouchers():
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 511e015ffa24489bc8c553626843b558acd558bd..e13d4747e0c2c3246c4ce012b7da911df0970212 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -88,6 +88,9 @@ from hypothesis.strategies import (
     binary,
     text,
     datetimes,
+    builds,
+    tuples,
+    dictionaries,
 )
 
 from twisted.python.filepath import (
@@ -106,6 +109,7 @@ from twisted.web.http import (
     UNAUTHORIZED,
     NOT_FOUND,
     BAD_REQUEST,
+    NOT_IMPLEMENTED,
 )
 from twisted.web.http_headers import (
     Headers,
@@ -141,8 +145,14 @@ from ..resource import (
     from_configuration,
 )
 
+from ..pricecalculator import (
+    PriceCalculator,
+)
+
 from ..storage_common import (
     required_passes,
+    get_configured_pass_value,
+    get_configured_lease_duration,
 )
 
 from .strategies import (
@@ -157,10 +167,12 @@ from .strategies import (
     requests,
     request_paths,
     api_auth_tokens,
+    share_parameters,
 )
 from .matchers import (
     Provides,
     matches_response,
+    between,
 )
 from .json import (
     loads,
@@ -269,7 +281,7 @@ def root_from_config(config, now):
     )
 
 
-def authorized_request(api_auth_token, agent, method, uri, data=None):
+def authorized_request(api_auth_token, agent, method, uri, headers=None, data=None):
     """
     Issue a request with the required token-based authorization header value.
 
@@ -281,6 +293,10 @@ def authorized_request(api_auth_token, agent, method, uri, data=None):
 
     :param bytes uri: The URI for the request.
 
+    :param ({bytes: [bytes]})|None headers: If not ``None``, extra request
+        headers to include.  The **Authorization** header will be overwritten
+        if it is present.
+
     :param BytesIO|None data: If not ``None``, the request body.
 
     :return: A ``Deferred`` like the one returned by ``IAgent.request``.
@@ -289,12 +305,18 @@ def authorized_request(api_auth_token, agent, method, uri, data=None):
         bodyProducer = None
     else:
         bodyProducer = FileBodyProducer(data, cooperator=uncooperator())
+    if headers is None:
+        headers = Headers()
+    else:
+        headers = Headers(headers)
+    headers.setRawHeaders(
+        u"authorization",
+        [b"tahoe-lafs {}".format(api_auth_token)],
+    )
     return agent.request(
         method,
         uri,
-        headers=Headers({
-            "authorization": ["tahoe-lafs {}".format(api_auth_token)],
-        }),
+        headers=headers,
         bodyProducer=bodyProducer,
     )
 
@@ -1310,6 +1332,245 @@ class VoucherTests(TestCase):
         )
 
 
+def mime_types(blacklist=None):
+    """
+    Build MIME types as b"major/minor" byte strings.
+
+    :param set|None blacklist: If not ``None``, MIME types to exclude from the
+        result.
+    """
+    if blacklist is None:
+        blacklist = set()
+    return tuples(
+        text(),
+        text(),
+    ).map(
+        b"/".join,
+    ).filter(
+        lambda content_type: content_type not in blacklist,
+    )
+
+
+@attr.s
+class Request(object):
+    """
+    Represent some of the parameters of an HTTP request.
+    """
+    method = attr.ib()
+    headers = attr.ib()
+    data = attr.ib()
+
+
+def bad_calculate_price_requests():
+    """
+    Build Request instances describing requests which are not allowed at the
+    ``/calculate-price`` endpoint.
+    """
+    good_methods = just(b"POST")
+    bad_methods = sampled_from([
+        b"GET",
+        b"HEAD",
+        b"PUT",
+        b"PATCH",
+        b"OPTIONS",
+        b"FOO",
+    ])
+
+    good_headers = just({b"content-type": [b"application/json"]})
+    bad_headers = fixed_dictionaries({
+        b"content-type": mime_types(
+            blacklist={b"application/json"},
+        ).map(
+            lambda content_type: [content_type],
+        ),
+    })
+
+    good_version = just(1)
+    bad_version = one_of(
+        text(),
+        lists(integers()),
+        integers(max_value=0),
+        integers(min_value=2),
+    )
+
+    good_sizes = lists(integers(min_value=0))
+    bad_sizes = one_of(
+        integers(),
+        text(),
+        lists(text(), min_size=1),
+        dictionaries(text(), text()),
+        lists(integers(max_value=-1), min_size=1),
+    )
+
+    good_data = fixed_dictionaries({
+        u"version": good_version,
+        u"sizes": good_sizes,
+    }).map(dumps)
+
+    bad_data_version = fixed_dictionaries({
+        u"version": bad_version,
+        u"sizes": good_sizes,
+    }).map(dumps)
+
+    bad_data_sizes = fixed_dictionaries({
+        u"version": good_version,
+        u"sizes": bad_sizes,
+    }).map(dumps)
+
+    bad_data_other = dictionaries(
+        text(),
+        integers(),
+    ).map(dumps)
+
+    bad_data_junk = binary()
+
+    good_fields = {
+        "method": good_methods,
+        "headers": good_headers,
+        "data": good_data,
+    }
+
+    bad_choices = [
+        ("method", bad_methods),
+        ("headers", bad_headers),
+        ("data", bad_data_version),
+        ("data", bad_data_sizes),
+        ("data", bad_data_other),
+        ("data", bad_data_junk),
+    ]
+
+    def merge(fields, key, value):
+        fields = fields.copy()
+        fields[key] = value
+        return fields
+
+    return sampled_from(
+        bad_choices,
+    ).flatmap(
+        lambda bad_choice: builds(
+            Request,
+            **merge(good_fields, *bad_choice)
+        ),
+    )
+
+
+class CalculatePriceTests(TestCase):
+    """
+    Tests relating to ``/calculate-price`` as implemented by the
+    ``_zkapauthorizer.resource`` module.
+    """
+    url = b"http://127.0.0.1/calculate-price"
+
+    @given(
+        tahoe_configs(),
+        api_auth_tokens(),
+        bad_calculate_price_requests(),
+    )
+    def test_bad_request(self, get_config, api_auth_token, bad_request):
+        """
+        When approached with:
+
+          * a method other than POST
+          * a content-type other than **application/json**
+          * a request body which is not valid JSON
+          * a JSON request body without version and sizes properties
+          * a JSON request body without a version of 1
+          * a JSON request body with other properties
+          * or a JSON request body with sizes other than a list of integers
+
+        response code is not in the 200 range.
+        """
+        config = get_config_with_api_token(
+            self.useFixture(TempDir()),
+            get_config,
+            api_auth_token,
+        )
+        root = root_from_config(config, datetime.now)
+        agent = RequestTraversalAgent(root)
+        self.assertThat(
+            authorized_request(
+                api_auth_token,
+                agent,
+                bad_request.method,
+                self.url,
+                headers=bad_request.headers,
+                data=BytesIO(bad_request.data),
+            ),
+            succeeded(
+                matches_response(
+                    code_matcher=MatchesAny(
+                        # It is fine to signal client errors
+                        between(400, 499),
+                        # It is fine to say we didn't implement the request
+                        # method (I guess - Twisted Web sort of forces it on
+                        # us, I'd rather have NOT ALLOWED for this case
+                        # instead...).  We don't want INTERNAL SERVER ERROR
+                        # though.
+                        Equals(NOT_IMPLEMENTED),
+                    ),
+                ),
+            ),
+        )
+
+    @given(
+        # Make the share encoding parameters easily accessible without going
+        # through the Tahoe-LAFS configuration.
+        share_parameters().flatmap(
+            lambda params: tuples(
+                just(params),
+                tahoe_configs(shares=just(params)),
+            ),
+        ),
+        api_auth_tokens(),
+        lists(integers(min_value=0)),
+    )
+    def test_calculated_price(self, encoding_params_and_get_config, api_auth_token, sizes):
+        """
+        A well-formed request returns the price in ZKAPs as an integer and the
+        storage period (the minimum allowed) that they pay for.
+        """
+        encoding_params, get_config = encoding_params_and_get_config
+        shares_needed, shares_happy, shares_total = encoding_params
+
+        config = get_config_with_api_token(
+            self.useFixture(TempDir()),
+            get_config,
+            api_auth_token,
+        )
+        root = root_from_config(config, datetime.now)
+        agent = RequestTraversalAgent(root)
+
+        expected_price = PriceCalculator(
+            shares_needed=shares_needed,
+            shares_total=shares_total,
+            pass_value=get_configured_pass_value(config),
+        ).calculate(sizes)
+
+        self.assertThat(
+            authorized_request(
+                api_auth_token,
+                agent,
+                b"POST",
+                self.url,
+                headers={b"content-type": [b"application/json"]},
+                data=BytesIO(dumps({u"version": 1, u"sizes": sizes})),
+            ),
+            succeeded(
+                matches_response(
+                    code_matcher=Equals(OK),
+                    headers_matcher=application_json(),
+                    body_matcher=AfterPreprocessing(
+                        loads,
+                        Equals({
+                            u"price": expected_price,
+                            u"period": get_configured_lease_duration(config),
+                        }),
+                    ),
+                ),
+            ),
+        )
+
+
 def application_json():
     return AfterPreprocessing(
         lambda h: h.getRawHeaders(u"content-type"),
diff --git a/src/_zkapauthorizer/tests/test_pricecalculator.py b/src/_zkapauthorizer/tests/test_pricecalculator.py
new file mode 100644
index 0000000000000000000000000000000000000000..25eaa40d09102b66ffbb592d590497c33d1c8517
--- /dev/null
+++ b/src/_zkapauthorizer/tests/test_pricecalculator.py
@@ -0,0 +1,227 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_zkapauthorizer.pricecalculator``.
+"""
+
+from functools import (
+    partial,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Equals,
+    GreaterThan,
+    IsInstance,
+    MatchesAll,
+)
+
+from hypothesis import (
+    given,
+)
+
+from hypothesis.strategies import (
+    integers,
+    lists,
+    tuples,
+)
+
+from ..pricecalculator import (
+    PriceCalculator,
+)
+
+from .strategies import (
+    sizes,
+    share_parameters,
+)
+from .matchers import (
+    greater_or_equal,
+)
+
+file_sizes = lists(sizes(), min_size=1)
+
+class PriceCalculatorTests(TestCase):
+    """
+    Tests for ``PriceCalculator``.
+    """
+    @given(
+        integers(min_value=1),
+        integers(min_value=1),
+        file_sizes,
+    )
+    def test_pass_value(self, pass_value, more_value, file_sizes):
+        """
+        The result of ``PriceCalculator.calculate`` increases or remains the same
+        as pass value decreases.
+        """
+        calculator = partial(PriceCalculator, shares_needed=1, shares_total=1)
+        less_value = calculator(pass_value=pass_value)
+        more_value = calculator(pass_value=pass_value + more_value)
+
+        less_value_price = less_value.calculate(file_sizes)
+        more_value_price = more_value.calculate(file_sizes)
+
+        self.assertThat(
+            less_value_price,
+            greater_or_equal(more_value_price),
+        )
+
+    @given(
+        integers(min_value=1, max_value=127),
+        integers(min_value=1, max_value=127),
+        file_sizes,
+    )
+    def test_shares_needed(self, shares_needed, more_needed, file_sizes):
+        """
+        The result of ``PriceCalculator.calculate`` never increases as
+        ``shares_needed`` increases.
+        """
+        calculator = partial(PriceCalculator, pass_value=100, shares_total=255)
+        fewer_needed = calculator(shares_needed=shares_needed)
+        more_needed = calculator(shares_needed=shares_needed + more_needed)
+
+        fewer_needed_price = fewer_needed.calculate(file_sizes)
+        more_needed_price = more_needed.calculate(file_sizes)
+
+        self.assertThat(
+            fewer_needed_price,
+            greater_or_equal(more_needed_price),
+        )
+
+
+    @given(
+        integers(min_value=1, max_value=127),
+        integers(min_value=1, max_value=127),
+        file_sizes,
+    )
+    def test_shares_total(self, shares_total, more_total, file_sizes):
+        """
+        The result of ``PriceCalculator.calculate`` always increases as
+        ``shares_total`` increases.
+        """
+        calculator = partial(PriceCalculator, pass_value=100, shares_needed=1)
+        fewer_total = calculator(shares_total=shares_total)
+        more_total = calculator(shares_total=shares_total + more_total)
+
+        fewer_total_price = fewer_total.calculate(file_sizes)
+        more_total_price = more_total.calculate(file_sizes)
+
+        self.assertThat(
+            more_total_price,
+            greater_or_equal(fewer_total_price),
+        )
+
+    @given(
+        integers(min_value=1, max_value=100).flatmap(
+            lambda num_files: tuples(
+                lists(sizes(), min_size=num_files, max_size=num_files),
+                lists(sizes(), min_size=num_files, max_size=num_files),
+            ),
+        ),
+        integers(min_value=1),
+        share_parameters(),
+    )
+    def test_file_sizes(self, file_sizes, pass_value, parameters):
+        """
+        The result of ``PriceCalculator.calculate`` never decreases as the values
+        of ``file_sizes`` increase.
+        """
+        smaller_sizes, increases = file_sizes
+        larger_sizes = list(a + b for (a, b) in zip(smaller_sizes, increases))
+        k, happy, N = parameters
+
+        calculator = PriceCalculator(
+            pass_value=pass_value,
+            shares_needed=k,
+            shares_total=N,
+        )
+
+        smaller_sizes_price = calculator.calculate(smaller_sizes)
+        larger_sizes_price = calculator.calculate(larger_sizes)
+
+        self.assertThat(
+            larger_sizes_price,
+            greater_or_equal(smaller_sizes_price),
+        )
+
+    @given(
+        integers(min_value=1),
+        share_parameters(),
+        file_sizes,
+    )
+    def test_positive_integer_price(self, pass_value, parameters, file_sizes):
+        """
+        The result of ``PriceCalculator.calculate`` for a non-empty size list is
+        always a positive integer.
+        """
+        k, happy, N = parameters
+        calculator = PriceCalculator(
+            pass_value=pass_value,
+            shares_needed=k,
+            shares_total=N,
+        )
+        price = calculator.calculate(file_sizes)
+        self.assertThat(
+            price,
+            MatchesAll(
+                IsInstance((int, long)),
+                GreaterThan(0),
+            ),
+        )
+
+    @given(
+        integers(min_value=1),
+        share_parameters(),
+        file_sizes,
+    )
+    def test_linear_increase(self, pass_value, parameters, file_sizes):
+        """
+        The result of ``PriceCalculator.calculate`` doubles if the file size list
+        is doubled.
+        """
+        k, happy, N = parameters
+        calculator = PriceCalculator(
+            pass_value=pass_value,
+            shares_needed=k,
+            shares_total=N,
+        )
+        smaller_price = calculator.calculate(file_sizes)
+        larger_price = calculator.calculate(file_sizes + file_sizes)
+        self.assertThat(
+            larger_price,
+            Equals(smaller_price * 2),
+        )
+
+    @given(
+        integers(min_value=1),
+    )
+    def test_one_pass(self, pass_value):
+        """
+        The result of ``PriceCalculator.calculate`` is exactly ``1`` if the amount
+        of data to be stored equals the value of a pass.
+        """
+        calculator = PriceCalculator(
+            pass_value=pass_value,
+            shares_needed=1,
+            shares_total=1,
+        )
+        price = calculator.calculate([pass_value])
+        self.assertThat(
+            price,
+            Equals(1),
+        )
diff --git a/src/_zkapauthorizer/tests/test_strategies.py b/src/_zkapauthorizer/tests/test_strategies.py
index 6a0307ea4b799204dba2c4f297473a0d52619215..3f60fcd8a617e344e20b7c5e2bb496143e07d571 100644
--- a/src/_zkapauthorizer/tests/test_strategies.py
+++ b/src/_zkapauthorizer/tests/test_strategies.py
@@ -34,6 +34,8 @@ from hypothesis import (
 )
 from hypothesis.strategies import (
     data,
+    one_of,
+    just,
 )
 
 from allmydata.client import (
@@ -42,6 +44,7 @@ from allmydata.client import (
 
 from .strategies import (
     tahoe_config_texts,
+    share_parameters,
 )
 
 class TahoeConfigsTests(TestCase):
@@ -54,7 +57,15 @@ class TahoeConfigsTests(TestCase):
         Configurations built by the strategy can be parsed.
         """
         tempdir = self.useFixture(TempDir())
-        config_text = data.draw(tahoe_config_texts({}))
+        config_text = data.draw(
+            tahoe_config_texts(
+                storage_client_plugins={},
+                shares=one_of(
+                    just((None, None, None)),
+                    share_parameters(),
+                ),
+            ),
+        )
         note(config_text)
         config_from_string(
             tempdir.join(b"tahoe.ini"),