diff --git a/CHANGELOG.md b/CHANGELOG.md
index c974b3a6..a31b80ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,90 +5,6 @@ All notable changes to Tailbone will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
-## v0.22.7 (2025-02-19)
-
-### Fix
-
-- stop using old config for logo image url on login page
-- fix warning msg for deprecated Grid param
-
-## v0.22.6 (2025-02-01)
-
-### Fix
-
-- register vue3 form component for products -> make batch
-
-## v0.22.5 (2024-12-16)
-
-### Fix
-
-- whoops this is latest rattail
-- require newer rattail lib
-- require newer wuttaweb
-- let caller request safe HTML literal for rendered grid table
-
-## v0.22.4 (2024-11-22)
-
-### Fix
-
-- avoid error in product search for duplicated key
-- use vmodel for confirm password widget input
-
-## v0.22.3 (2024-11-19)
-
-### Fix
-
-- avoid error for trainwreck query when not a customer
-
-## v0.22.2 (2024-11-18)
-
-### Fix
-
-- use local/custom enum for continuum operations
-- add basic master view for Product Costs
-- show continuum operation type when viewing version history
-- always define `app` attr for ViewSupplement
-- avoid deprecated import
-
-## v0.22.1 (2024-11-02)
-
-### Fix
-
-- fix submit button for running problem report
-- avoid deprecated grid method
-
-## v0.22.0 (2024-10-22)
-
-### Feat
-
-- add support for new ordering batch from parsed file
-
-### Fix
-
-- avoid deprecated method to suggest username
-
-## v0.21.11 (2024-10-03)
-
-### Fix
-
-- custom method for adding grid action
-- become/stop root should redirect to previous url
-
-## v0.21.10 (2024-09-15)
-
-### Fix
-
-- update project repo links, kallithea -> forgejo
-- use better icon for submit button on login page
-- wrap notes text for batch view
-- expose datasync consumer batch size via configure page
-
-## v0.21.9 (2024-08-28)
-
-### Fix
-
-- render custom attrs in form component tag
-
 ## v0.21.8 (2024-08-28)
 
 ### Fix
diff --git a/README.md b/README.rst
similarity index 56%
rename from README.md
rename to README.rst
index 74c007f6..0cffc62d 100644
--- a/README.md
+++ b/README.rst
@@ -1,8 +1,10 @@
 
-# Tailbone
+Tailbone
+========
 
 Tailbone is an extensible web application based on Rattail.  It provides a
 "back-office network environment" (BONE) for use in managing retail data.
 
-Please see Rattail's [home page](http://rattailproject.org/) for more
-information.
+Please see Rattail's `home page`_ for more information.
+
+.. _home page: http://rattailproject.org/
diff --git a/docs/conf.py b/docs/conf.py
index ade4c92a..52e384f5 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -27,10 +27,10 @@ templates_path = ['_templates']
 exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
 
 intersphinx_mapping = {
-    'rattail': ('https://docs.wuttaproject.org/rattail/', None),
+    'rattail': ('https://rattailproject.org/docs/rattail/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
-    'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
-    'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
+    'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
+    'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
 }
 
 # allow todo entries to show up
diff --git a/pyproject.toml b/pyproject.toml
index a7214a8e..350803dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,9 +6,9 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.7"
+version = "0.21.8"
 description = "Backoffice Web Application for Rattail"
-readme = "README.md"
+readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
 license = {text = "GNU GPL v3+"}
 classifiers = [
@@ -53,13 +53,13 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.20.1",
+        "rattail[db,bouncer]>=0.18.5",
         "sa-filters",
         "simplejson",
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.21.0",
+        "WuttaWeb>=0.14.0",
         "zope.sqlalchemy>=1.5",
 ]
 
@@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension"
 
 [project.urls]
 Homepage = "https://rattailproject.org"
-Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
-Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
-Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
+Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone"
+Issues = "https://redmine.rattailproject.org/projects/tailbone/issues"
+Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md"
 
 
 [tool.commitizen]
diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index b23bff55..daa4290f 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -29,7 +29,8 @@ import logging
 import humanize
 import sqlalchemy as sa
 
-from rattail.db.model import PurchaseBatch, PurchaseBatchRow
+from rattail.db import model
+from rattail.util import pretty_quantity
 
 from cornice import Service
 from deform import widget as dfwidget
@@ -44,7 +45,7 @@ log = logging.getLogger(__name__)
 
 class ReceivingBatchViews(APIBatchView):
 
-    model_class = PurchaseBatch
+    model_class = model.PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receivingbatchviews'
     permission_prefix = 'receiving'
@@ -54,8 +55,7 @@ class ReceivingBatchViews(APIBatchView):
     supports_execute = True
 
     def base_query(self):
-        model = self.app.model
-        query = super().base_query()
+        query = super(ReceivingBatchViews, self).base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
         return query
 
@@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView):
 
         # assume "receive from PO" if given a PO key
         if data.get('purchase_key'):
-            data['workflow'] = 'from_po'
+            data['receiving_workflow'] = 'from_po'
 
         return super().create_object(data)
 
@@ -120,7 +120,6 @@ class ReceivingBatchViews(APIBatchView):
         return self._get(obj=batch)
 
     def eligible_purchases(self):
-        model = self.app.model
         uuid = self.request.params.get('vendor_uuid')
         vendor = self.Session.get(model.Vendor, uuid) if uuid else None
         if not vendor:
@@ -177,7 +176,7 @@ class ReceivingBatchViews(APIBatchView):
 
 class ReceivingBatchRowViews(APIBatchRowView):
 
-    model_class = PurchaseBatchRow
+    model_class = model.PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receiving.rows'
     permission_prefix = 'receiving'
@@ -186,8 +185,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
     supports_quick_entry = True
 
     def make_filter_spec(self):
-        model = self.app.model
-        filters = super().make_filter_spec()
+        filters = super(ReceivingBatchRowViews, self).make_filter_spec()
         if filters:
 
             # must translate certain convenience filters
@@ -298,11 +296,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
         return filters
 
     def normalize(self, row):
-        data = super().normalize(row)
-        model = self.app.model
+        data = super(ReceivingBatchRowViews, self).normalize(row)
 
         batch = row.batch
-        prodder = self.app.get_products_handler()
+        app = self.get_rattail_app()
+        prodder = app.get_products_handler()
 
         data['product_uuid'] = row.product_uuid
         data['item_id'] = row.item_id
@@ -377,7 +375,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = self.app.render_quantity(remainder)
+                        remainder = pretty_quantity(remainder)
                         data['quick_receive_quantity'] = remainder
                         data['quick_receive_text'] = "Receive Remainder ({} {})".format(
                             remainder, data['unit_uom'])
@@ -388,7 +386,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         log.warning("quick receive remainder is empty for row %s", row.uuid)
-                    remainder = self.app.render_quantity(remainder)
+                    remainder = pretty_quantity(remainder)
                     data['quick_receive_quantity'] = remainder
                     data['quick_receive_text'] = "Receive ALL ({} {})".format(
                         remainder, data['unit_uom'])
@@ -416,7 +414,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
             data['received_alert'] = None
             if self.batch_handler.get_units_confirmed(row):
                 msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(self.app.make_utc() - row.modified))
+                    humanize.naturaltime(app.make_utc() - row.modified))
                 data['received_alert'] = msg
 
         return data
@@ -425,8 +423,6 @@ class ReceivingBatchRowViews(APIBatchRowView):
         """
         View which handles "receiving" against a particular batch row.
         """
-        model = self.app.model
-
         # first do basic input validation
         schema = ReceiveRow().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index 551d6428..2d17339e 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -26,6 +26,7 @@ Tailbone Web API - Master View
 
 import json
 
+from rattail.config import parse_bool
 from rattail.db.util import get_fieldnames
 
 from cornice import resource, Service
@@ -184,7 +185,7 @@ class APIMasterView(APIView):
             if sortcol:
                 spec = {
                     'field': sortcol.field_name,
-                    'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
+                    'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
                 }
                 if sortcol.model_name:
                     spec['model'] = sortcol.model_name
diff --git a/tailbone/app.py b/tailbone/app.py
index d2d0c5ef..b7262866 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -62,17 +62,6 @@ def make_rattail_config(settings):
     # nb. this is for compaibility with wuttaweb
     settings['wutta_config'] = rattail_config
 
-    # must import all sqlalchemy models before things get rolling,
-    # otherwise can have errors about continuum TransactionMeta class
-    # not yet mapped, when relevant pages are first requested...
-    # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
-    # hat tip to https://stackoverflow.com/a/59241485
-    if getattr(rattail_config, 'tempmon_engine', None):
-        from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
-        tempmon_session = TempmonSession()
-        tempmon_session.query(tempmon_model.Appliance).first()
-        tempmon_session.close()
-
     # configure database sessions
     if hasattr(rattail_config, 'appdb_engine'):
         tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 2e582b15..98253c57 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2024 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -270,21 +270,9 @@ class VersionDiff(Diff):
         for field in self.fields:
             values[field] = {'before': self.render_old_value(field),
                              'after': self.render_new_value(field)}
-
-        operation = None
-        if self.version.operation_type == continuum.Operation.INSERT:
-            operation = 'INSERT'
-        elif self.version.operation_type == continuum.Operation.UPDATE:
-            operation = 'UPDATE'
-        elif self.version.operation_type == continuum.Operation.DELETE:
-            operation = 'DELETE'
-        else:
-            operation = self.version.operation_type
-
         return {
             'key': id(self.version),
             'model_title': self.title,
-            'operation': operation,
             'diff_class': self.nature,
             'fields': self.fields,
             'values': values,
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 4024557b..b5020975 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -401,8 +401,6 @@ class Form(object):
         self.edit_help_url = edit_help_url
         self.route_prefix = route_prefix
 
-        self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
-
     def __iter__(self):
         return iter(self.fields)
 
@@ -1039,9 +1037,9 @@ class Form(object):
 
     def render_vue_tag(self, **kwargs):
         """ """
-        return self.render_vuejs_component(**kwargs)
+        return self.render_vuejs_component()
 
-    def render_vuejs_component(self, **kwargs):
+    def render_vuejs_component(self):
         """
         Render the Vue.js component HTML for the form.
 
@@ -1052,11 +1050,10 @@ class Form(object):
            <tailbone-form :configure-fields-help="configureFieldsHelp">
            </tailbone-form>
         """
-        kw = dict(self.vuejs_component_kwargs)
-        kw.update(kwargs)
+        kwargs = dict(self.vuejs_component_kwargs)
         if self.can_edit_help:
-            kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
-        return HTML.tag(self.vue_tagname, **kw)
+            kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
+        return HTML.tag(self.vue_tagname, **kwargs)
 
     def set_json_data(self, key, value):
         """
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 56b97b86..c6257d4b 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -235,7 +235,7 @@ class Grid(WuttaGrid):
 
         if 'pageable' in kwargs:
             warnings.warn("pageable param is deprecated for Grid(); "
-                          "please use paginated param instead",
+                          "please use vue_tagname param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('paginated', kwargs.pop('pageable'))
 
@@ -1223,7 +1223,6 @@ class Grid(WuttaGrid):
 
     def render_table_element(self, template='/grids/b-table.mako',
                              data_prop='gridData', empty_labels=False,
-                             literal=False,
                              **kwargs):
         """
         This is intended for ad-hoc "small" grids with static data.  Renders
@@ -1240,10 +1239,7 @@ class Grid(WuttaGrid):
         if context['paginated']:
             context.setdefault('per_page', 20)
         context['view_click_handler'] = self.get_view_click_handler()
-        result = render(template, context)
-        if literal:
-            result = HTML.literal(result)
-        return result
+        return render(template, context)
 
     def get_view_click_handler(self):
         """ """
@@ -1548,11 +1544,6 @@ class Grid(WuttaGrid):
         self._table_data = results
         return self._table_data
 
-    # TODO: remove this when we use upstream GridAction
-    def add_action(self, key, **kwargs):
-        """ """
-        self.actions.append(GridAction(self.request, key, **kwargs))
-
     def set_action_urls(self, row, rowobj, i):
         """
         Pre-generate all action URLs for the given data row.  Meant for use
diff --git a/tailbone/menus.py b/tailbone/menus.py
index 09d6f3f0..3ddee095 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -394,11 +394,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'products',
                     'perm': 'products.list',
                 },
-                {
-                    'title': "Product Costs",
-                    'route': 'product_costs',
-                    'perm': 'product_costs.list',
-                },
                 {
                     'title': "Departments",
                     'route': 'departments',
@@ -456,11 +451,6 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'vendors',
                     'perm': 'vendors.list',
                 },
-                {
-                    'title': "Product Costs",
-                    'route': 'product_costs',
-                    'perm': 'product_costs.list',
-                },
                 {'type': 'sep'},
                 {
                     'title': "Ordering",
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 8228f823..86b1ba1d 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -632,23 +632,9 @@
         % endif
         <div class="navbar-dropdown">
           % if request.is_root:
-              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
-              ${h.csrf_token(request)}
-              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="$refs.stopBeingRootForm.submit()"
-                 class="navbar-item root-user">
-                Stop being root
-              </a>
-              ${h.end_form()}
+              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
           % elif request.is_admin:
-              ${h.form(url('become_root'), ref='startBeingRootForm')}
-              ${h.csrf_token(request)}
-              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="$refs.startBeingRootForm.submit()"
-                 class="navbar-item root-user">
-                Become root
-              </a>
-              ${h.end_form()}
+              ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
           % endif
           % if messaging_enabled:
               ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 2e444fb5..3651d0c4 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -83,8 +83,8 @@
   </b-notification>
 
   <b-field>
-    <b-checkbox name="rattail.datasync.use_profile_settings"
-                v-model="simpleSettings['rattail.datasync.use_profile_settings']"
+    <b-checkbox name="use_profile_settings"
+                v-model="useProfileSettings"
                 native-value="true"
                 @input="settingsNeedSaved = true">
       Use these Settings to configure watchers and consumers
@@ -99,7 +99,7 @@
     </div>
     <div class="level-right">
       <div class="level-item"
-           v-show="simpleSettings['rattail.datasync.use_profile_settings']">
+           v-show="useProfileSettings">
         <b-button type="is-primary"
                   @click="newProfile()"
                   icon-pack="fas"
@@ -162,7 +162,7 @@
       </${b}-table-column>
       <${b}-table-column label="Actions"
                       v-slot="props"
-                      v-if="simpleSettings['rattail.datasync.use_profile_settings']">
+                      v-if="useProfileSettings">
         <a href="#"
            class="grid-action"
            @click.prevent="editProfile(props.row)">
@@ -580,27 +580,18 @@
   <b-field label="Supervisor Process Name"
            message="This should be the complete name, including group - e.g. poser:poser_datasync"
            expanded>
-    <b-input name="rattail.datasync.supervisor_process_name"
-             v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
+    <b-input name="supervisor_process_name"
+             v-model="supervisorProcessName"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
   </b-field>
 
-  <b-field label="Consumer Batch Size"
-           message="Max number of changes to be consumed at once."
-           expanded>
-    <numeric-input name="rattail.datasync.batch_size_limit"
-                   v-model="simpleSettings['rattail.datasync.batch_size_limit']"
-                   @input="settingsNeedSaved = true" />
-  </b-field>
-
-  <h3 class="is-size-3">Legacy</h3>
   <b-field label="Restart Command"
            message="This will run as '${system_user}' system user - please configure sudoers as needed.  Typical command is like:  sudo supervisorctl restart poser:poser_datasync"
            expanded>
-    <b-input name="tailbone.datasync.restart"
-             v-model="simpleSettings['tailbone.datasync.restart']"
+    <b-input name="restart_command"
+             v-model="restartCommand"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
@@ -615,6 +606,7 @@
     ThisPageData.showConfigFilesNote = false
     ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
     ThisPageData.showDisabledProfiles = false
+    ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
 
     ThisPageData.editProfileShowDialog = false
     ThisPageData.editingProfile = null
@@ -639,6 +631,9 @@
     ThisPageData.editingConsumerRunas = null
     ThisPageData.editingConsumerEnabled = true
 
+    ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
+    ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
+
     ThisPage.computed.updateConsumerDisabled = function() {
         if (!this.editingConsumerKey) {
             return true
diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt
index 2121f01d..f78c0b85 100644
--- a/tailbone/templates/deform/checked_password.pt
+++ b/tailbone/templates/deform/checked_password.pt
@@ -1,7 +1,6 @@
 <div i18n:domain="deform" tal:omit-tag=""
       tal:define="oid oid|field.oid;
                   name name|field.name;
-                  vmodel vmodel|'field_model_' + name;
                   css_class css_class|field.widget.css_class;
                   style style|field.widget.style;">
 
@@ -9,7 +8,7 @@
     ${field.start_mapping()}
     <b-input type="password"
              name="${name}"
-             v-model="${vmodel}"
+             value="${field.widget.redisplay and cstruct or ''}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              attributes|field.widget.attributes|{};"
@@ -19,6 +18,7 @@
     </b-input>
     <b-input type="password"
              name="${name}-confirm"
+             value="${field.widget.redisplay and confirm or ''}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              confirm_attributes|field.widget.confirm_attributes|{};"
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 2100b460..ea35ab17 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -59,7 +59,7 @@
                       native-type="submit"
                       :disabled="${form.vue_component}Submitting"
                       icon-pack="fas"
-                      icon-left="${form.button_icon_submit}">
+                      icon-left="save">
               {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
             </b-button>
         % else:
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 118c028c..0a1f9c62 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -196,7 +196,6 @@
 
                   <p class="block has-text-weight-bold">
                     {{ version.model_title }}
-                    ({{ version.operation }})
                   </p>
 
                   <table class="diff monospace is-size-7"
diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako
deleted file mode 100644
index dc505c42..00000000
--- a/tailbone/templates/ordering/configure.mako
+++ /dev/null
@@ -1,74 +0,0 @@
-## -*- coding: utf-8; -*-
-<%inherit file="/configure.mako" />
-
-<%def name="form_content()">
-
-  <h3 class="block is-size-3">Workflows</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <p class="block">
-      Users can only choose from the workflows enabled below.
-    </p>
-
-    <b-field>
-      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
-                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        From Scratch
-      </b-checkbox>
-    </b-field>
-
-    <b-field>
-      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
-                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        From Order File
-      </b-checkbox>
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Vendors</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
-      <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
-                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Allow ordering for <span class="has-text-weight-bold">any</span> vendor
-      </b-checkbox>
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Order Parsers</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <p class="block">
-      Only the selected file parsers will be exposed to users.
-    </p>
-
-    % for Parser in order_parsers:
-        <b-field message="${Parser.key}">
-          <b-checkbox name="order_parser_${Parser.key}"
-                      v-model="orderParsers['${Parser.key}']"
-                      native-value="true"
-                      @input="settingsNeedSaved = true">
-            ${Parser.title}
-          </b-checkbox>
-        </b-field>
-    % endfor
-
-  </div>
-
-</%def>
-
-<%def name="modify_vue_vars()">
-  ${parent.modify_vue_vars()}
-  <script>
-    ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
-  </script>
-</%def>
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index db029e5a..9f969468 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -55,20 +55,19 @@
 </%def>
 
 <%def name="render_form_template()">
-  <script type="text/x-template" id="${form.vue_tagname}-template">
+  <script type="text/x-template" id="${form.component}-template">
     ${self.render_form_innards()}
   </script>
 </%def>
 
 <%def name="modify_vue_vars()">
   ${parent.modify_vue_vars()}
-  <% request.register_component(form.vue_tagname, form.vue_component) %>
   <script>
 
     ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
     let ${form.vue_component} = {
-        template: '#${form.vue_tagname}-template',
+        template: '#${form.component}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index a36dde43..f613e13e 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -69,12 +69,12 @@
   <h3 class="block is-size-3">Vendors</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
-      <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
-                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
+    <b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.supported_vendors_only"
+                  v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']"
                   native-value="true"
                   @input="settingsNeedSaved = true">
-        Allow receiving for <span class="has-text-weight-bold">any</span> vendor
+        Only allow batch for "supported" vendors
       </b-checkbox>
     </b-field>
 
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 5cdf2be5..00ac1503 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -45,10 +45,11 @@
             <b-button @click="runReportShowDialog = false">
               Cancel
             </b-button>
-            ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
+            ${h.form(master.get_action_url('execute', instance))}
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       native-type="submit"
+                      @click="runReportSubmitting = true"
                       :disabled="runReportSubmitting"
                       icon-pack="fas"
                       icon-left="arrow-circle-right">
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index b69eacfb..14616474 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -909,7 +909,7 @@
               ${h.form(url('stop_root'), ref='stopBeingRootForm')}
               ${h.csrf_token(request)}
               <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="$refs.stopBeingRootForm.submit()"
+              <a @click="stopBeingRoot()"
                  class="navbar-item has-background-danger has-text-white">
                 Stop being root
               </a>
@@ -918,7 +918,7 @@
               ${h.form(url('become_root'), ref='startBeingRootForm')}
               ${h.csrf_token(request)}
               <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="$refs.startBeingRootForm.submit()"
+              <a @click="startBeingRoot()"
                  class="navbar-item has-background-danger has-text-white">
                 Become root
               </a>
@@ -1103,6 +1103,18 @@
                 const key = 'menu_' + hash + '_shown'
                 this[key] = !this[key]
             },
+
+            % if request.is_admin:
+
+                startBeingRoot() {
+                    this.$refs.startBeingRootForm.submit()
+                },
+
+                stopBeingRoot() {
+                    this.$refs.stopBeingRootForm.submit()
+                },
+
+            % endif
         },
     }
 
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index eceab803..730d7b6a 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -24,6 +24,8 @@
 Auth Views
 """
 
+from rattail.db.auth import set_user_password
+
 import colander
 from deform import widget as dfwidget
 from pyramid.httpexceptions import HTTPForbidden
@@ -44,6 +46,28 @@ class UserLogin(colander.MappingSchema):
                                    widget=dfwidget.PasswordWidget())
 
 
+@colander.deferred
+def current_password_correct(node, kw):
+    request = kw['request']
+    app = request.rattail_config.get_app()
+    auth = app.get_auth_handler()
+    user = kw['user']
+    def validate(node, value):
+        if not auth.authenticate_user(Session(), user.username, value):
+            raise colander.Invalid(node, "The password is incorrect")
+    return validate
+
+
+class ChangePassword(colander.MappingSchema):
+
+    current_password = colander.SchemaNode(colander.String(),
+                                           widget=dfwidget.PasswordWidget(),
+                                           validator=current_password_correct)
+
+    new_password = colander.SchemaNode(colander.String(),
+                                       widget=dfwidget.CheckedPasswordWidget())
+
+
 class AuthenticationView(View):
 
     def forbidden(self):
@@ -80,7 +104,6 @@ class AuthenticationView(View):
         form.save_label = "Login"
         form.show_reset = True
         form.show_cancel = False
-        form.button_icon_submit = 'user'
         if form.validate():
             user = self.authenticate_user(form.validated['username'],
                                           form.validated['password'])
@@ -94,6 +117,10 @@ class AuthenticationView(View):
             else:
                 self.request.session.flash("Invalid username or password", 'error')
 
+        image_url = self.rattail_config.get(
+            'tailbone', 'main_image_url',
+            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+
         # nb. hacky..but necessary, to add the refs, for autofocus
         # (also add key handler, so ENTER acts like TAB)
         dform = form.make_deform_form()
@@ -106,6 +133,7 @@ class AuthenticationView(View):
         return {
             'form': form,
             'referrer': referrer,
+            'image_url': image_url,
             'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }
@@ -154,27 +182,10 @@ class AuthenticationView(View):
                 self.request.user))
             return self.redirect(self.request.get_referrer())
 
-        def check_user_password(node, value):
-            auth = self.app.get_auth_handler()
-            user = self.request.user
-            if not auth.check_user_password(user, value):
-                node.raise_invalid("The password is incorrect")
-
-        schema = colander.Schema()
-
-        schema.add(colander.SchemaNode(colander.String(),
-                                       name='current_password',
-                                       widget=dfwidget.PasswordWidget(),
-                                       validator=check_user_password))
-
-        schema.add(colander.SchemaNode(colander.String(),
-                                       name='new_password',
-                                       widget=dfwidget.CheckedPasswordWidget()))
-
+        schema = ChangePassword().bind(user=self.request.user, request=self.request)
         form = forms.Form(schema=schema, request=self.request)
         if form.validate():
-            auth = self.app.get_auth_handler()
-            auth.set_user_password(self.request.user, form.validated['new_password'])
+            set_user_password(self.request.user, form.validated['new_password'])
             self.request.session.flash("Your password has been changed.")
             return self.redirect(self.request.get_referrer())
 
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index c162b579..8ee3a37d 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -46,11 +46,10 @@ import colander
 from deform import widget as dfwidget
 from webhelpers2.html import HTML, tags
 
-from wuttaweb.util import render_csrf_token
-
 from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView
+from tailbone.util import csrf_token
 
 
 log = logging.getLogger(__name__)
@@ -384,7 +383,7 @@ class BatchMasterView(MasterView):
         f.set_label('executed_by', "Executed by")
 
         # notes
-        f.set_type('notes', 'text_wrapped')
+        f.set_type('notes', 'text')
 
         # if self.creating and self.request.user:
         #     batch = fs.model
@@ -442,7 +441,7 @@ class BatchMasterView(MasterView):
 
         form = [
             begin_form,
-            render_csrf_token(self.request),
+            csrf_token(self.request),
             tags.hidden('complete', value=value),
             submit,
             tags.end_form(),
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index 2b955b5f..134d6018 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -202,36 +202,10 @@ class DataSyncThreadView(MasterView):
         return self.redirect(self.request.get_referrer(
             default=self.request.route_url('datasyncchanges')))
 
-    def configure_get_simple_settings(self):
-        """ """
-        return [
-
-            # basic
-            {'section': 'rattail.datasync',
-             'option': 'use_profile_settings',
-             'type': bool},
-
-            # misc.
-            {'section': 'rattail.datasync',
-             'option': 'supervisor_process_name'},
-            {'section': 'rattail.datasync',
-             'option': 'batch_size_limit',
-             'type': int},
-
-            # legacy
-            {'section': 'tailbone',
-             'option': 'datasync.restart'},
-
-        ]
-
-    def configure_get_context(self, **kwargs):
-        """ """
-        context = super().configure_get_context(**kwargs)
-
+    def configure_get_context(self):
         profiles = self.datasync_handler.get_configured_profiles(
             include_disabled=True,
             ignore_problems=True)
-        context['profiles'] = profiles
 
         profiles_data = []
         for profile in sorted(profiles.values(), key=lambda p: p.key):
@@ -269,15 +243,25 @@ class DataSyncThreadView(MasterView):
             data['consumers_data'] = consumers
             profiles_data.append(data)
 
-        context['profiles_data'] = profiles_data
-        return context
+        return {
+            'profiles': profiles,
+            'profiles_data': profiles_data,
+            'use_profile_settings': self.datasync_handler.should_use_profile_settings(),
+            'supervisor_process_name': self.rattail_config.get(
+                'rattail.datasync', 'supervisor_process_name'),
+            'restart_command': self.rattail_config.get(
+                'tailbone', 'datasync.restart'),
+        }
 
-    def configure_gather_settings(self, data, **kwargs):
-        """ """
-        settings = super().configure_gather_settings(data, **kwargs)
+    def configure_gather_settings(self, data):
+        settings = []
+        watch = []
 
-        if data.get('rattail.datasync.use_profile_settings') == 'true':
-            watch = []
+        use_profile_settings = data.get('use_profile_settings') == 'true'
+        settings.append({'name': 'rattail.datasync.use_profile_settings',
+                         'value': 'true' if use_profile_settings else 'false'})
+
+        if use_profile_settings:
 
             for profile in json.loads(data['profiles']):
                 pkey = profile['key']
@@ -339,12 +323,17 @@ class DataSyncThreadView(MasterView):
                 settings.append({'name': 'rattail.datasync.watch',
                                  'value': ', '.join(watch)})
 
+        if data['supervisor_process_name']:
+            settings.append({'name': 'rattail.datasync.supervisor_process_name',
+                             'value': data['supervisor_process_name']})
+
+        if data['restart_command']:
+            settings.append({'name': 'tailbone.datasync.restart',
+                             'value': data['restart_command']})
+
         return settings
 
-    def configure_remove_settings(self, **kwargs):
-        """ """
-        super().configure_remove_settings(**kwargs)
-
+    def configure_remove_settings(self):
         purge_datasync_settings(self.rattail_config, self.Session())
 
     @classmethod
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 21a5e58f..baf63caa 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -412,7 +412,7 @@ class MasterView(View):
             session = self.Session()
         kwargs.setdefault('paginated', False)
         grid = self.make_grid(session=session, **kwargs)
-        return grid.get_visible_data()
+        return grid.make_visible_data()
 
     def get_grid_columns(self):
         """
@@ -903,7 +903,7 @@ class MasterView(View):
 
     def valid_employee_uuid(self, node, value):
         if value:
-            model = self.app.model
+            model = self.model
             employee = self.Session.get(model.Employee, value)
             if not employee:
                 node.raise_invalid("Employee not found")
@@ -939,7 +939,7 @@ class MasterView(View):
 
     def valid_vendor_uuid(self, node, value):
         if value:
-            model = self.app.model
+            model = self.model
             vendor = self.Session.get(model.Vendor, value)
             if not vendor:
                 node.raise_invalid("Vendor not found")
@@ -1382,7 +1382,7 @@ class MasterView(View):
         return classes
 
     def make_revisions_grid(self, obj, empty_data=False):
-        model = self.app.model
+        model = self.model
         route_prefix = self.get_route_prefix()
         row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
                                                         uuid=obj.uuid,
@@ -1710,7 +1710,7 @@ class MasterView(View):
         kwargs.setdefault('paginated', False)
         kwargs.setdefault('sortable', sort)
         grid = self.make_row_grid(session=session, **kwargs)
-        return grid.get_visible_data()
+        return grid.make_visible_data()
 
     @classmethod
     def get_row_url_prefix(cls):
@@ -2153,7 +2153,7 @@ class MasterView(View):
         Thread target for executing an object.
         """
         app = self.get_rattail_app()
-        model = self.app.model
+        model = self.model
         session = app.make_session()
         obj = self.get_instance_for_key(key, session)
         user = session.get(model.User, user_uuid)
@@ -2594,7 +2594,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.app.model
+        model = self.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2617,7 +2617,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.app.model
+        model = self.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2639,7 +2639,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.app.model
+        model = self.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -2673,7 +2673,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.app.model
+        model = self.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -5541,7 +5541,7 @@ class MasterView(View):
                                   input_file_templates=True,
                                   output_file_templates=True):
         app = self.get_rattail_app()
-        model = self.app.model
+        model = self.model
         names = []
 
         if simple_settings is None:
@@ -6100,7 +6100,7 @@ class MasterView(View):
                         renderer='json')
 
 
-class ViewSupplement:
+class ViewSupplement(object):
     """
     Base class for view "supplements" - which are sort of like plugins
     which can "supplement" certain aspects of the view.
@@ -6127,7 +6127,6 @@ class ViewSupplement:
     def __init__(self, master):
         self.master = master
         self.request = master.request
-        self.app = master.app
         self.model = master.model
         self.rattail_config = master.rattail_config
         self.Session = master.Session
@@ -6161,7 +6160,7 @@ class ViewSupplement:
         This is accomplished by subjecting the current base query to a
         join, e.g. something like::
 
-           model = self.app.model
+           model = self.model
            query = query.outerjoin(model.MyExtension)
            return query
         """
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 405b1ca3..b6a4c0b9 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -564,19 +564,15 @@ class PersonView(MasterView):
         Method which must return the base query for the profile's POS
         Transactions grid data.
         """
-        customer = self.app.get_customer(person)
+        app = self.get_rattail_app()
+        customer = app.get_customer(person)
 
-        if customer:
-            key_field = self.app.get_customer_key_field()
-            customer_key = getattr(customer, key_field)
-            if customer_key is not None:
-                customer_key = str(customer_key)
-        else:
-            # nb. this should *not* match anything, so query returns
-            # no results..
-            customer_key = person.uuid
+        key_field = app.get_customer_key_field()
+        customer_key = getattr(customer, key_field)
+        if customer_key is not None:
+            customer_key = str(customer_key)
 
-        trainwreck = self.app.get_trainwreck_handler()
+        trainwreck = app.get_trainwreck_handler()
         model = trainwreck.get_model()
         query = TrainwreckSession.query(model.Transaction)\
                                  .filter(model.Transaction.customer_id == customer_key)
@@ -1386,8 +1382,8 @@ class PersonView(MasterView):
         }
 
         if not context['users']:
-            context['suggested_username'] = auth.make_unique_username(self.Session(),
-                                                                      person=person)
+            context['suggested_username'] = auth.generate_unique_username(self.Session(),
+                                                                          person=person)
 
         return context
 
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index 8461ae03..c546a0f4 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
 
 from rattail import enum, pod, sil
 from rattail.db import api, auth, Session as RattailSession
-from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
+from rattail.db.model import Product, PendingProduct, CustomerOrderItem
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
@@ -1857,8 +1857,7 @@ class ProductView(MasterView):
             lookup_fields.append('alt_code')
         if lookup_fields:
             product = self.products_handler.locate_product_for_entry(
-                session, term, lookup_fields=lookup_fields,
-                first_if_multiple=True)
+                session, term, lookup_fields=lookup_fields)
             if product:
                 final_results.append(self.search_normalize_result(product))
 
@@ -2669,78 +2668,6 @@ class PendingProductView(MasterView):
                         permission=f'{permission_prefix}.ignore_product')
 
 
-class ProductCostView(MasterView):
-    """
-    Master view for Product Costs
-    """
-    model_class = ProductCost
-    route_prefix = 'product_costs'
-    url_prefix = '/products/costs'
-    has_versions = True
-
-    grid_columns = [
-        '_product_key_',
-        'vendor',
-        'preference',
-        'code',
-        'case_size',
-        'case_cost',
-        'pack_size',
-        'pack_cost',
-        'unit_cost',
-    ]
-
-    def query(self, session):
-        """ """
-        query = super().query(session)
-        model = self.app.model
-
-        # always join on Product
-        return query.join(model.Product)
-
-    def configure_grid(self, g):
-        """ """
-        super().configure_grid(g)
-        model = self.app.model
-
-        # product key
-        field = self.get_product_key_field()
-        g.set_renderer(field, self.render_product_key)
-        g.set_sorter(field, getattr(model.Product, field))
-        g.set_sort_defaults(field)
-        g.set_filter(field, getattr(model.Product, field))
-
-        # vendor
-        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
-        g.set_sorter('vendor', model.Vendor.name)
-        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
-
-    def render_product_key(self, cost, field):
-        """ """
-        handler = self.app.get_products_handler()
-        return handler.render_product_key(cost.product)
-
-    def configure_form(self, f):
-        """ """
-        super().configure_form(f)
-
-        # product
-        f.set_renderer('product', self.render_product)
-        if 'product_uuid' in f and 'product' in f:
-            f.remove('product')
-            f.replace('product_uuid', 'product')
-
-        # vendor
-        f.set_renderer('vendor', self.render_vendor)
-        if 'vendor_uuid' in f and 'vendor' in f:
-            f.remove('vendor')
-            f.replace('vendor_uuid', 'vendor')
-
-        # futures
-        # TODO: should eventually show a subgrid here?
-        f.remove('futures')
-
-
 def defaults(config, **kwargs):
     base = globals()
 
@@ -2750,9 +2677,6 @@ def defaults(config, **kwargs):
     PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
     PendingProductView.defaults(config)
 
-    ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
-    ProductCostView.defaults(config)
-
 
 def includeme(config):
     defaults(config)
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 5e00704e..590b9af5 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -24,8 +24,6 @@
 Base class for purchasing batch views
 """
 
-import warnings
-
 from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
@@ -69,8 +67,6 @@ class PurchasingBatchView(BatchMasterView):
         'store',
         'buyer',
         'vendor',
-        'description',
-        'workflow',
         'department',
         'purchase',
         'vendor_email',
@@ -162,174 +158,6 @@ class PurchasingBatchView(BatchMasterView):
     def batch_mode(self):
         raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
 
-    def get_supported_workflows(self):
-        """
-        Return the supported "create batch" workflows.
-        """
-        enum = self.app.enum
-        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
-            return self.batch_handler.supported_ordering_workflows()
-        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
-            return self.batch_handler.supported_receiving_workflows()
-        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
-            return self.batch_handler.supported_costing_workflows()
-        raise ValueError("unknown batch mode")
-
-    def allow_any_vendor(self):
-        """
-        Return boolean indicating whether creating a batch for "any"
-        vendor is allowed, vs. only supported vendors.
-        """
-        enum = self.app.enum
-
-        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
-            return self.batch_handler.allow_ordering_any_vendor()
-
-        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
-            value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
-            if value is not None:
-                return value
-            value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
-            if value is not None:
-                warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
-                              "please use rattail.batch.purchase.allow_receiving_any_vendor instead",
-                              DeprecationWarning)
-                # nb. must negate this setting
-                return not value
-            return False
-
-        raise ValueError("unknown batch mode")
-
-    def get_supported_vendors(self):
-        """
-        Return the supported vendors for creating a batch.
-        """
-        return []
-
-    def create(self, form=None, **kwargs):
-        """
-        Custom view for creating a new batch.  We split the process
-        into two steps, 1) choose workflow and 2) create batch.  This
-        is because the specific form details for creating a batch will
-        depend on which "type" of batch creation is to be done, and
-        it's much easier to keep conditional logic for that in the
-        server instead of client-side etc.
-        """
-        model = self.app.model
-        enum = self.app.enum
-        route_prefix = self.get_route_prefix()
-
-        workflows = self.get_supported_workflows()
-        valid_workflows = [workflow['workflow_key']
-                           for workflow in workflows]
-
-        # if user has already identified their desired workflow, then
-        # we can just farm out to the default logic.  we will of
-        # course configure our form differently, based on workflow,
-        # but this create() method at least will not need
-        # customization for that.
-        if self.request.matched_route.name.endswith('create_workflow'):
-
-            redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
-
-            # however we do have one more thing to check - the workflow
-            # requested must of course be valid!
-            workflow_key = self.request.matchdict['workflow_key']
-            if workflow_key not in valid_workflows:
-                self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
-                raise redirect
-
-            # also, we require vendor to be correctly identified.  if
-            # someone e.g. navigates to a URL by accident etc. we want
-            # to gracefully handle and redirect
-            uuid = self.request.matchdict['vendor_uuid']
-            vendor = self.Session.get(model.Vendor, uuid)
-            if not vendor:
-                self.request.session.flash("Invalid vendor selection.  "
-                                           "Please choose an existing vendor.",
-                                           'warning')
-                raise redirect
-
-            # okay now do the normal thing, per workflow
-            return super().create(**kwargs)
-
-        # on the other hand, if caller provided a form, that means we are in
-        # the middle of some other custom workflow, e.g. "add child to truck
-        # dump parent" or some such.  in which case we also defer to the normal
-        # logic, so as to not interfere with that.
-        if form:
-            return super().create(form=form, **kwargs)
-
-        # okay, at this point we need the user to select a vendor and workflow
-        self.creating = True
-        context = {}
-
-        # form to accept user choice of vendor/workflow
-        schema = colander.Schema()
-        schema.add(colander.SchemaNode(colander.String(), name='vendor'))
-        schema.add(colander.SchemaNode(colander.String(), name='workflow',
-                                       validator=colander.OneOf(valid_workflows)))
-        factory = self.get_form_factory()
-        form = factory(schema=schema, request=self.request)
-
-        # configure vendor field
-        vendor_handler = self.app.get_vendor_handler()
-        if self.allow_any_vendor():
-            # user may choose *any* available vendor
-            use_dropdown = vendor_handler.choice_uses_dropdown()
-            if use_dropdown:
-                vendors = self.Session.query(model.Vendor)\
-                                      .order_by(model.Vendor.id)\
-                                      .all()
-                vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
-                                 for vendor in vendors]
-                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-                if len(vendors) == 1:
-                    form.set_default('vendor', vendors[0].uuid)
-            else:
-                vendor_display = ""
-                if self.request.method == 'POST':
-                    if self.request.POST.get('vendor'):
-                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
-                        if vendor:
-                            vendor_display = str(vendor)
-                vendors_url = self.request.route_url('vendors.autocomplete')
-                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=vendor_display, service_url=vendors_url))
-        else: # only "supported" vendors allowed
-            vendors = self.get_supported_vendors()
-            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
-                             for vendor in vendors]
-            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-        form.set_validator('vendor', self.valid_vendor_uuid)
-
-        # configure workflow field
-        values = [(workflow['workflow_key'], workflow['display'])
-                  for workflow in workflows]
-        form.set_widget('workflow',
-                        dfwidget.SelectWidget(values=values))
-        if len(workflows) == 1:
-            form.set_default('workflow', workflows[0]['workflow_key'])
-
-        form.submit_label = "Continue"
-        form.cancel_url = self.get_index_url()
-
-        # if form validates, that means user has chosen a creation
-        # type, so we just redirect to the appropriate "new batch of
-        # type X" page
-        if form.validate():
-            workflow_key = form.validated['workflow']
-            vendor_uuid = form.validated['vendor']
-            url = self.request.route_url(f'{route_prefix}.create_workflow',
-                                         workflow_key=workflow_key,
-                                         vendor_uuid=vendor_uuid)
-            raise self.redirect(url)
-
-        context['form'] = form
-        if hasattr(form, 'make_deform_form'):
-            context['dform'] = form.make_deform_form()
-        return self.render_to_response('create', context)
-
     def query(self, session):
         model = self.model
         return session.query(model.PurchaseBatch)\
@@ -398,40 +226,20 @@ class PurchasingBatchView(BatchMasterView):
 
     def configure_form(self, f):
         super().configure_form(f)
-        model = self.app.model
-        enum = self.app.enum
-        route_prefix = self.get_route_prefix()
-
-        today = self.app.today()
+        model = self.model
         batch = f.model_instance
-        workflow = self.request.matchdict.get('workflow_key')
-        vendor_handler = self.app.get_vendor_handler()
+        app = self.get_rattail_app()
+        today = app.localtime().date()
 
         # mode
-        f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
-
-        # workflow
-        if self.creating:
-            if workflow:
-                f.set_widget('workflow', dfwidget.HiddenWidget())
-                f.set_default('workflow', workflow)
-                f.set_hidden('workflow')
-                # nb. show readonly '_workflow'
-                f.insert_after('workflow', '_workflow')
-                f.set_readonly('_workflow')
-                f.set_renderer('_workflow', self.render_workflow)
-            else:
-                f.set_readonly('workflow')
-                f.set_renderer('workflow', self.render_workflow)
-        else:
-            f.remove('workflow')
+        f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE)
 
         # store
-        single_store = self.config.single_store()
+        single_store = self.rattail_config.single_store()
         if self.creating:
             f.replace('store', 'store_uuid')
             if single_store:
-                store = self.config.get_store(self.Session())
+                store = self.rattail_config.get_store(self.Session())
                 f.set_widget('store_uuid', dfwidget.HiddenWidget())
                 f.set_default('store_uuid', store.uuid)
                 f.set_hidden('store_uuid')
@@ -455,6 +263,7 @@ class PurchasingBatchView(BatchMasterView):
         if self.creating:
             f.replace('vendor', 'vendor_uuid')
             f.set_label('vendor_uuid', "Vendor")
+            vendor_handler = app.get_vendor_handler()
             use_dropdown = vendor_handler.choice_uses_dropdown()
             if use_dropdown:
                 vendors = self.Session.query(model.Vendor)\
@@ -504,7 +313,7 @@ class PurchasingBatchView(BatchMasterView):
                         if buyer:
                             buyer_display = str(buyer)
                 elif self.creating:
-                    buyer = self.app.get_employee(self.request.user)
+                    buyer = app.get_employee(self.request.user)
                     if buyer:
                         buyer_display = str(buyer)
                         f.set_default('buyer_uuid', buyer.uuid)
@@ -515,30 +324,6 @@ class PurchasingBatchView(BatchMasterView):
                     field_display=buyer_display, service_url=buyers_url))
                 f.set_label('buyer_uuid', "Buyer")
 
-        # order_file
-        if self.creating:
-            f.set_type('order_file', 'file', required=False)
-        else:
-            f.set_readonly('order_file')
-            f.set_renderer('order_file', self.render_downloadable_file)
-
-        # order_parser_key
-        if self.creating:
-            kwargs = {}
-            if 'vendor_uuid' in self.request.matchdict:
-                vendor = self.Session.get(model.Vendor,
-                                          self.request.matchdict['vendor_uuid'])
-                if vendor:
-                    kwargs['vendor'] = vendor
-            parsers = vendor_handler.get_supported_order_parsers(**kwargs)
-            parser_values = [(p.key, p.title) for p in parsers]
-            if len(parsers) == 1:
-                f.set_default('order_parser_key', parsers[0].key)
-            f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
-            f.set_label('order_parser_key', "Order Parser")
-        else:
-            f.remove_field('order_parser_key')
-
         # invoice_file
         if self.creating:
             f.set_type('invoice_file', 'file', required=False)
@@ -556,7 +341,7 @@ class PurchasingBatchView(BatchMasterView):
                 if vendor:
                     kwargs['vendor'] = vendor
 
-            parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
+            parsers = self.handler.get_supported_invoice_parsers(**kwargs)
             parser_values = [(p.key, p.display) for p in parsers]
             if len(parsers) == 1:
                 f.set_default('invoice_parser_key', parsers[0].key)
@@ -615,35 +400,6 @@ class PurchasingBatchView(BatchMasterView):
                             'vendor_contact',
                             'status_code')
 
-        # tweak some things if we are in "step 2" of creating new batch
-        if self.creating and workflow:
-
-            # display vendor but do not allow changing
-            vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
-            if not vendor:
-                raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
-            f.set_readonly('vendor_uuid')
-            f.set_default('vendor_uuid', str(vendor))
-
-            # cancel should take us back to choosing a workflow
-            f.cancel_url = self.request.route_url(f'{route_prefix}.create')
-
-    def render_workflow(self, batch, field):
-        key = self.request.matchdict['workflow_key']
-        info = self.get_workflow_info(key)
-        if info:
-            return info['display']
-
-    def get_workflow_info(self, key):
-        enum = self.app.enum
-        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
-            return self.batch_handler.ordering_workflow_info(key)
-        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
-            return self.batch_handler.receiving_workflow_info(key)
-        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
-            return self.batch_handler.costing_workflow_info(key)
-        raise ValueError("unknown batch mode")
-
     def render_store(self, batch, field):
         store = batch.store
         if not store:
@@ -759,12 +515,10 @@ class PurchasingBatchView(BatchMasterView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
-        model = self.app.model
+        model = self.model
 
         kwargs['mode'] = self.batch_mode
-        kwargs['workflow'] = self.request.POST['workflow']
         kwargs['truck_dump'] = batch.truck_dump
-        kwargs['order_parser_key'] = batch.order_parser_key
         kwargs['invoice_parser_key'] = batch.invoice_parser_key
 
         if batch.store:
@@ -782,11 +536,6 @@ class PurchasingBatchView(BatchMasterView):
         elif batch.vendor_uuid:
             kwargs['vendor_uuid'] = batch.vendor_uuid
 
-        # must pull vendor from URL if it was not in form data
-        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
-            if 'vendor_uuid' in self.request.matchdict:
-                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
-
         if batch.department:
             kwargs['department'] = batch.department
         elif batch.department_uuid:
@@ -1170,25 +919,6 @@ class PurchasingBatchView(BatchMasterView):
 #         # otherwise just view batch again
 #         return self.get_action_url('view', batch)
 
-    @classmethod
-    def defaults(cls, config):
-        cls._purchase_batch_defaults(config)
-        cls._batch_defaults(config)
-        cls._defaults(config)
-
-    @classmethod
-    def _purchase_batch_defaults(cls, config):
-        route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
-        permission_prefix = cls.get_permission_prefix()
-
-        # new batch using workflow X
-        config.add_route(f'{route_prefix}.create_workflow',
-                         f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
-        config.add_view(cls, attr='create',
-                        route_name=f'{route_prefix}.create_workflow',
-                        permission=f'{permission_prefix}.create')
-
 
 class NewProduct(colander.Schema):
 
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index c7cc7bfc..2e24eebb 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2024 Lance Edgar
+#  Copyright © 2010-2023 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,10 +28,14 @@ import os
 import json
 
 import openpyxl
+from sqlalchemy import orm
 
+from rattail.db import model, api
 from rattail.core import Object
+from rattail.time import localtime
+
+from webhelpers2.html import tags
 
-from tailbone.db import Session
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -47,8 +51,6 @@ class OrderingBatchView(PurchasingBatchView):
     rows_editable = True
     has_worksheet = True
     default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
-    downloadable = True
-    configurable = True
 
     labels = {
         'po_total_calculated': "PO Total",
@@ -57,14 +59,9 @@ class OrderingBatchView(PurchasingBatchView):
     form_fields = [
         'id',
         'store',
-        'vendor',
-        'description',
-        'workflow',
-        'order_file',
-        'order_parser_key',
         'buyer',
+        'vendor',
         'department',
-        'params',
         'purchase',
         'vendor_email',
         'vendor_fax',
@@ -135,26 +132,15 @@ class OrderingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_ORDERING
 
     def configure_form(self, f):
-        super().configure_form(f)
+        super(OrderingBatchView, self).configure_form(f)
         batch = f.model_instance
-        workflow = self.request.matchdict.get('workflow_key')
 
         # purchase
         if self.creating or not batch.executed or not batch.purchase:
             f.remove_field('purchase')
 
-        # now that all fields are setup, some final tweaks based on workflow
-        if self.creating and workflow:
-
-            if workflow == 'from_scratch':
-                f.remove('order_file',
-                         'order_parser_key')
-
-            elif workflow == 'from_file':
-                f.set_required('order_file')
-
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super().get_batch_kwargs(batch, **kwargs)
+        kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs)
         kwargs['ship_method'] = batch.ship_method
         kwargs['notes_to_vendor'] = batch.notes_to_vendor
         return kwargs
@@ -169,7 +155,7 @@ class OrderingBatchView(PurchasingBatchView):
         * ``cases_ordered``
         * ``units_ordered``
         """
-        super().configure_row_form(f)
+        super(OrderingBatchView, self).configure_row_form(f)
 
         # when editing, only certain fields should allow changes
         if self.editing:
@@ -322,7 +308,7 @@ class OrderingBatchView(PurchasingBatchView):
         title = self.get_instance_title(batch)
         order_date = batch.date_ordered
         if not order_date:
-            order_date = self.app.today()
+            order_date = localtime(self.rattail_config).date()
 
         return self.render_to_response('worksheet', {
             'batch': batch,
@@ -383,7 +369,6 @@ class OrderingBatchView(PurchasingBatchView):
         of being updated.  If a matching row is not found, it will not be
         created.
         """
-        model = self.app.model
         batch = self.get_instance()
 
         try:
@@ -493,75 +478,13 @@ class OrderingBatchView(PurchasingBatchView):
         return self.file_response(path)
 
     def get_execute_success_url(self, batch, result, **kwargs):
-        model = self.app.model
         if isinstance(result, model.Purchase):
             return self.request.route_url('purchases.view', uuid=result.uuid)
-        return super().get_execute_success_url(batch, result, **kwargs)
-
-    def configure_get_simple_settings(self):
-        return [
-
-            # workflows
-            {'section': 'rattail.batch',
-             'option': 'purchase.allow_ordering_from_scratch',
-             'type': bool,
-             'default': True},
-            {'section': 'rattail.batch',
-             'option': 'purchase.allow_ordering_from_file',
-             'type': bool,
-             'default': True},
-
-            # vendors
-            {'section': 'rattail.batch',
-             'option': 'purchase.allow_ordering_any_vendor',
-             'type': bool,
-             'default': True,
-             },
-        ]
-
-    def configure_get_context(self):
-        context = super().configure_get_context()
-        vendor_handler = self.app.get_vendor_handler()
-
-        Parsers = vendor_handler.get_all_order_parsers()
-        Supported = vendor_handler.get_supported_order_parsers()
-        context['order_parsers'] = Parsers
-        context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
-                                                for Parser in Parsers])
-
-        return context
-
-    def configure_gather_settings(self, data):
-        settings = super().configure_gather_settings(data)
-        vendor_handler = self.app.get_vendor_handler()
-
-        supported = []
-        for Parser in vendor_handler.get_all_order_parsers():
-            name = f'order_parser_{Parser.key}'
-            if data.get(name) == 'true':
-                supported.append(Parser.key)
-        settings.append({'name': 'rattail.vendors.supported_order_parsers',
-                         'value': ', '.join(supported)})
-
-        return settings
-
-    def configure_remove_settings(self):
-        super().configure_remove_settings()
-
-        names = [
-            'rattail.vendors.supported_order_parsers',
-        ]
-
-        # nb. using thread-local session here; we do not use
-        # self.Session b/c it may not point to Rattail
-        session = Session()
-        for name in names:
-            self.app.delete_setting(session, name)
+        return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs)
 
     @classmethod
     def defaults(cls, config):
         cls._ordering_defaults(config)
-        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 01858c98..de19a2b9 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'store',
         'vendor',
         'description',
-        'workflow',
+        'receiving_workflow',
         'truck_dump',
         'truck_dump_children_first',
         'truck_dump_children',
@@ -235,18 +235,135 @@ class ReceivingBatchView(PurchasingBatchView):
         if not self.handler.allow_truck_dump_receiving():
             g.remove('truck_dump')
 
-    def get_supported_vendors(self):
-        """ """
-        vendor_handler = self.app.get_vendor_handler()
-        vendors = {}
-        for parser in self.batch_handler.get_supported_invoice_parsers():
-            if parser.vendor_key:
-                vendor = vendor_handler.get_vendor(self.Session(),
-                                                   parser.vendor_key)
-                if vendor:
-                    vendors[vendor.uuid] = vendor
-        vendors = sorted(vendors.values(), key=lambda v: v.name)
-        return vendors
+    def create(self, form=None, **kwargs):
+        """
+        Custom view for creating a new receiving batch.  We split the process
+        into two steps, 1) choose and 2) create.  This is because the specific
+        form details for creating a batch will depend on which "type" of batch
+        creation is to be done, and it's much easier to keep conditional logic
+        for that in the server instead of client-side etc.
+
+        See also
+        :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()`
+        which uses similar logic.
+        """
+        model = self.model
+        route_prefix = self.get_route_prefix()
+        workflows = self.handler.supported_receiving_workflows()
+        valid_workflows = [workflow['workflow_key']
+                           for workflow in workflows]
+
+        # if user has already identified their desired workflow, then we can
+        # just farm out to the default logic.  we will of course configure our
+        # form differently, based on workflow, but this create() method at
+        # least will not need customization for that.
+        if self.request.matched_route.name.endswith('create_workflow'):
+
+            redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
+
+            # however we do have one more thing to check - the workflow
+            # requested must of course be valid!
+            workflow_key = self.request.matchdict['workflow_key']
+            if workflow_key not in valid_workflows:
+                self.request.session.flash(
+                    "Not a supported workflow: {}".format(workflow_key),
+                    'error')
+                raise redirect
+
+            # also, we require vendor to be correctly identified.  if
+            # someone e.g. navigates to a URL by accident etc. we want
+            # to gracefully handle and redirect
+            uuid = self.request.matchdict['vendor_uuid']
+            vendor = self.Session.get(model.Vendor, uuid)
+            if not vendor:
+                self.request.session.flash("Invalid vendor selection.  "
+                                           "Please choose an existing vendor.",
+                                           'warning')
+                raise redirect
+
+            # okay now do the normal thing, per workflow
+            return super().create(**kwargs)
+
+        # on the other hand, if caller provided a form, that means we are in
+        # the middle of some other custom workflow, e.g. "add child to truck
+        # dump parent" or some such.  in which case we also defer to the normal
+        # logic, so as to not interfere with that.
+        if form:
+            return super().create(form=form, **kwargs)
+
+        # okay, at this point we need the user to select a vendor and workflow
+        self.creating = True
+        context = {}
+
+        # form to accept user choice of vendor/workflow
+        schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
+        form = forms.Form(schema=schema, request=self.request)
+
+        # configure vendor field
+        app = self.get_rattail_app()
+        vendor_handler = app.get_vendor_handler()
+        if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
+            # only show vendors for which we have dedicated invoice parsers
+            vendors = {}
+            for parser in self.batch_handler.get_supported_invoice_parsers():
+                if parser.vendor_key:
+                    vendor = vendor_handler.get_vendor(self.Session(),
+                                                       parser.vendor_key)
+                    if vendor:
+                        vendors[vendor.uuid] = vendor
+            vendors = sorted(vendors.values(), key=lambda v: v.name)
+            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
+                             for vendor in vendors]
+            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+        else:
+            # user may choose *any* available vendor
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if use_dropdown:
+                vendors = self.Session.query(model.Vendor)\
+                                      .order_by(model.Vendor.id)\
+                                      .all()
+                vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
+                                 for vendor in vendors]
+                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+                if len(vendors) == 1:
+                    form.set_default('vendor', vendors[0].uuid)
+            else:
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                vendors_url = self.request.route_url('vendors.autocomplete')
+                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display, service_url=vendors_url))
+        form.set_validator('vendor', self.valid_vendor_uuid)
+
+        # configure workflow field
+        values = [(workflow['workflow_key'], workflow['display'])
+                  for workflow in workflows]
+        form.set_widget('workflow',
+                        dfwidget.SelectWidget(values=values))
+        if len(workflows) == 1:
+            form.set_default('workflow', workflows[0]['workflow_key'])
+
+        form.submit_label = "Continue"
+        form.cancel_url = self.get_index_url()
+
+        # if form validates, that means user has chosen a creation type, so we
+        # just redirect to the appropriate "new batch of type X" page
+        if form.validate():
+            workflow_key = form.validated['workflow']
+            vendor_uuid = form.validated['vendor']
+            url = self.request.route_url('{}.create_workflow'.format(route_prefix),
+                                         workflow_key=workflow_key,
+                                         vendor_uuid=vendor_uuid)
+            raise self.redirect(url)
+
+        context['form'] = form
+        if hasattr(form, 'make_deform_form'):
+            context['dform'] = form.make_deform_form()
+        return self.render_to_response('create', context)
 
     def row_deletable(self, row):
 
@@ -287,7 +404,13 @@ class ReceivingBatchView(PurchasingBatchView):
             # cancel should take us back to choosing a workflow
             f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
 
-        # TODO: remove this
+        # receiving_workflow
+        if self.creating and workflow:
+            f.set_readonly('receiving_workflow')
+            f.set_renderer('receiving_workflow', self.render_receiving_workflow)
+        else:
+            f.remove('receiving_workflow')
+
         # batch_type
         if self.creating:
             f.set_widget('batch_type', dfwidget.HiddenWidget())
@@ -402,7 +525,7 @@ class ReceivingBatchView(PurchasingBatchView):
 
         # multiple invoice files (if applicable)
         if (not self.creating
-            and batch.get_param('workflow') == 'from_multi_invoice'):
+            and batch.get_param('receiving_workflow') == 'from_multi_invoice'):
 
             if 'invoice_files' not in f:
                 f.insert_before('invoice_file', 'invoice_files')
@@ -501,6 +624,12 @@ class ReceivingBatchView(PurchasingBatchView):
             items.append(HTML.tag('li', c=[link]))
         return HTML.tag('ul', c=items)
 
+    def render_receiving_workflow(self, batch, field):
+        key = self.request.matchdict['workflow_key']
+        info = self.handler.receiving_workflow_info(key)
+        if info:
+            return info['display']
+
     def get_visible_params(self, batch):
         params = super().get_visible_params(batch)
 
@@ -525,40 +654,42 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
+        batch_type = self.request.POST['batch_type']
 
         # must pull vendor from URL if it was not in form data
         if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
             if 'vendor_uuid' in self.request.matchdict:
                 kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
 
-        workflow = kwargs['workflow']
-        if workflow == 'from_scratch':
+        # TODO: ugh should just have workflow and no batch_type
+        kwargs['receiving_workflow'] = batch_type
+        if batch_type == 'from_scratch':
             kwargs.pop('truck_dump_batch', None)
             kwargs.pop('truck_dump_batch_uuid', None)
-        elif workflow == 'from_invoice':
+        elif batch_type == 'from_invoice':
             pass
-        elif workflow == 'from_multi_invoice':
+        elif batch_type == 'from_multi_invoice':
             pass
-        elif workflow == 'from_po':
+        elif batch_type == 'from_po':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif workflow == 'from_po_with_invoice':
+        elif batch_type == 'from_po_with_invoice':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif workflow == 'truck_dump_children_first':
+        elif batch_type == 'truck_dump_children_first':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_children_first'] = True
             kwargs['order_quantities_known'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif workflow == 'truck_dump_children_last':
+        elif batch_type == 'truck_dump_children_last':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_ready'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif workflow.startswith('truck_dump_child'):
+        elif batch_type.startswith('truck_dump_child'):
             truck_dump = self.get_instance()
             kwargs['store'] = truck_dump.store
             kwargs['vendor'] = truck_dump.vendor
@@ -1855,12 +1986,6 @@ class ReceivingBatchView(PurchasingBatchView):
              'type': bool},
 
             # vendors
-            {'section': 'rattail.batch',
-             'option': 'purchase.allow_receiving_any_vendor',
-             'type': bool},
-            # TODO: deprecated; can remove this once all live config
-            # is updated.  but for now it remains so this setting is
-            # auto-deleted
             {'section': 'rattail.batch',
              'option': 'purchase.supported_vendors_only',
              'type': bool},
@@ -1911,7 +2036,6 @@ class ReceivingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._receiving_defaults(config)
-        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
@@ -1919,11 +2043,17 @@ class ReceivingBatchView(PurchasingBatchView):
     def _receiving_defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
         route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
         instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
         permission_prefix = cls.get_permission_prefix()
 
+        # new receiving batch using workflow X
+        config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
+        config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
+                        permission='{}.create'.format(permission_prefix))
+
         # row-level receiving
         config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
         config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
@@ -1976,6 +2106,33 @@ class ReceivingBatchView(PurchasingBatchView):
                         permission='{}.auto_receive'.format(permission_prefix))
 
 
+@colander.deferred
+def valid_workflow(node, kw):
+    """
+    Deferred validator for ``workflow`` field, for new batches.
+    """
+    valid_workflows = kw['valid_workflows']
+
+    def validate(node, value):
+        # we just need to provide possible values, and let stock validator
+        # handle the rest
+        oneof = colander.OneOf(valid_workflows)
+        return oneof(node, value)
+
+    return validate
+
+
+class NewReceivingBatch(colander.Schema):
+    """
+    Schema for choosing which "type" of new receiving batch should be created.
+    """
+    vendor = colander.SchemaNode(colander.String(),
+                                 label="Vendor")
+
+    workflow = colander.SchemaNode(colander.String(),
+                                   validator=valid_workflow)
+
+
 class ReceiveRowForm(colander.MappingSchema):
 
     mode = colander.SchemaNode(colander.String(),
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index ffa88032..3276b64d 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -348,27 +348,56 @@ class UpgradeView(MasterView):
     commit_hash_pattern = re.compile(r'^.{40}$')
 
     def get_changelog_projects(self):
-        project_map = {
-            'onager': 'onager',
-            'pyCOREPOS': 'pycorepos',
-            'rattail': 'rattail',
-            'rattail_corepos': 'rattail-corepos',
-            'rattail-onager': 'rattail-onager',
-            'rattail_tempmon': 'rattail-tempmon',
-            'rattail_woocommerce': 'rattail-woocommerce',
-            'Tailbone': 'tailbone',
-            'tailbone_corepos': 'tailbone-corepos',
-            'tailbone-onager': 'tailbone-onager',
-            'tailbone_theo': 'theo',
-            'tailbone_woocommerce': 'tailbone-woocommerce',
+        projects = {
+            'rattail': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst',
+            },
+            'Tailbone': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst',
+            },
+            'pyCOREPOS': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst',
+            },
+            'rattail_corepos': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst',
+            },
+            'tailbone_corepos': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst',
+            },
+            'onager': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst',
+            },
+            'rattail-onager': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md',
+            },
+            'rattail_tempmon': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst',
+            },
+            'tailbone-onager': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md',
+            },
+            'rattail_woocommerce': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst',
+            },
+            'tailbone_woocommerce': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst',
+            },
+            'tailbone_theo': {
+                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10',
+                'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst',
+            },
         }
-
-        projects = {}
-        for name, repo in project_map.items():
-            projects[name] = {
-                'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}',
-                'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md',
-            }
         return projects
 
     def get_changelog_url(self, project, old_version, new_version):