Surface add-on prices and add an add-ons total sensor

Add-on rows already carried quantity, unit and extended price; only the name
was exposed. The attribute now renders the full line and a new monetary
sensor reports the add-ons subtotal separately from the box.

The fixture totals were copied from a seven-add-on cart while the fixture
itself had one, so they are now self-consistent and a test asserts that
add-ons plus box price equals the portal's subtotal.
This commit is contained in:
flan
2026-08-03 19:15:19 +00:00
parent a97f0aec6a
commit 527f662189
10 changed files with 174 additions and 32 deletions
+18
View File
@@ -5,6 +5,24 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2026-08-03
### Added
- `sensor.fresh_harvest_next_delivery_add_ons`, the combined cost of the
add-ons excluding the produce box.
- `add_ons_total` and `box_price` attributes on the item-count sensor.
- A test asserting `add_ons_total + box_price` equals the portal's own
subtotal, so a mis-parsed price cannot pass silently.
### Changed
- `add_ons` attribute entries now carry quantity, unit, and extended price
(`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`) rather than a bare name.
Templates reading these strings will need updating.
- The test fixture's totals are now internally consistent, so the subtotal
reconciliation is a real invariant rather than copied numbers.
## [0.1.0] - 2026-08-03
### Added
+7 -1
View File
@@ -9,10 +9,16 @@ the Georgia local-produce delivery subscription.
| --- | --- | --- |
| `sensor.fresh_harvest_next_delivery` | `2026-08-04` | Attributes: `delivery_day`, `box` |
| `sensor.fresh_harvest_next_delivery_total` | `109.06` | Attributes: `subtotal`, `tax`, `delivery_fee` |
| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `produce`, `add_ons` |
| `sensor.fresh_harvest_next_delivery_add_ons` | `72.88` | Add-ons only, excluding the box. Attributes: `box_price`, `add_ons` |
| `sensor.fresh_harvest_next_delivery_items` | `14` | Attributes: `box`, `box_price`, `produce`, `add_ons`, `add_ons_total` |
| `sensor.fresh_harvest_open_order_delivery` | `2026-08-11` | The order you can still change |
| `sensor.fresh_harvest_shopping_window` | `Shop tomorrow` | `closed` when nothing is customizable |
Each `add_ons` entry carries its quantity, unit, and extended price — a
multi-quantity line bills as one amount, so 4 smoothies read
`4 Complete Recovery Smoothie 15.2 fl oz — $17.96`. `add_ons_total` plus
`box_price` always equals the portal's own subtotal, which a test asserts.
The next delivery and the *open* order are usually two different deliveries.
Once an order passes its cutoff it locks for packing, and the cart you can still
edit is the following week's.
+9
View File
@@ -110,6 +110,15 @@ class DeliveryOrder:
def all_items(self) -> list[OrderItem]:
return [*self.items, *self.addons]
@property
def addons_total(self) -> float:
"""Combined cost of the add-ons, excluding the produce box itself.
Add-on prices are already extended (4 smoothies bill as one $17.96
line), so this is a plain sum. It should equal `subtotal - box_price`.
"""
return round(sum(a.price for a in self.addons if a.price is not None), 2)
@dataclass
class AccountSnapshot:
+7 -3
View File
@@ -1,12 +1,16 @@
{
"domain": "freshharvest",
"name": "Fresh Harvest",
"codeowners": ["@flan"],
"codeowners": [
"@flan"
],
"config_flow": true,
"documentation": "https://git.onetick.ninja/flan/ha-freshharvest",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://git.onetick.ninja/flan/ha-freshharvest/issues",
"requirements": ["beautifulsoup4>=4.12"],
"version": "0.1.0"
"requirements": [
"beautifulsoup4>=4.12"
],
"version": "0.2.0"
}
+31 -6
View File
@@ -18,21 +18,30 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FreshHarvestConfigEntry
from .api import AccountSnapshot, DeliveryOrder
from .api import AccountSnapshot, DeliveryOrder, OrderItem
from .const import DOMAIN
from .coordinator import FreshHarvestCoordinator
def _format_item(item: OrderItem, with_price: bool = False) -> str:
"""Render one line, e.g. '4 Complete Recovery Smoothie 15.2 fl oz — $17.96'."""
line = " ".join(
part for part in (str(item.quantity or ""), item.name, item.unit) if part
)
if with_price and item.price is not None:
line = f"{line} — ${item.price:,.2f}"
return line
def _items_attrs(order: DeliveryOrder | None) -> dict[str, Any] | None:
if order is None:
return None
return {
"box": order.box_name,
"produce": [
" ".join(part for part in (str(i.quantity or ""), i.name, i.unit) if part)
for i in order.items
],
"add_ons": [a.name for a in order.addons],
"box_price": order.box_price,
"produce": [_format_item(i) for i in order.items],
"add_ons": [_format_item(a, with_price=True) for a in order.addons],
"add_ons_total": order.addons_total,
}
@@ -70,6 +79,22 @@ SENSORS: tuple[FreshHarvestSensorDescription, ...] = (
"delivery_fee": s.next_order.delivery_fee,
},
),
FreshHarvestSensorDescription(
key="next_delivery_addons_total",
translation_key="next_delivery_addons_total",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="USD",
value_fn=lambda s: s.next_order.addons_total if s.next_order else None,
attrs_fn=lambda s: None
if s.next_order is None
else {
"box_price": s.next_order.box_price,
"add_ons": [
_format_item(a, with_price=True) for a in s.next_order.addons
],
},
),
FreshHarvestSensorDescription(
key="next_delivery_items",
translation_key="next_delivery_items",
+18 -5
View File
@@ -19,11 +19,24 @@
},
"entity": {
"sensor": {
"next_delivery": { "name": "Next delivery" },
"next_delivery_total": { "name": "Next delivery total" },
"next_delivery_items": { "name": "Next delivery items" },
"open_order_delivery": { "name": "Open order delivery" },
"shop_window": { "name": "Shopping window" }
"next_delivery": {
"name": "Next delivery"
},
"next_delivery_total": {
"name": "Next delivery total"
},
"next_delivery_addons_total": {
"name": "Next delivery add-ons"
},
"next_delivery_items": {
"name": "Next delivery items"
},
"open_order_delivery": {
"name": "Open order delivery"
},
"shop_window": {
"name": "Shopping window"
}
}
}
}
@@ -19,11 +19,24 @@
},
"entity": {
"sensor": {
"next_delivery": { "name": "Next delivery" },
"next_delivery_total": { "name": "Next delivery total" },
"next_delivery_items": { "name": "Next delivery items" },
"open_order_delivery": { "name": "Open order delivery" },
"shop_window": { "name": "Shopping window" }
"next_delivery": {
"name": "Next delivery"
},
"next_delivery_total": {
"name": "Next delivery total"
},
"next_delivery_addons_total": {
"name": "Next delivery add-ons"
},
"next_delivery_items": {
"name": "Next delivery items"
},
"open_order_delivery": {
"name": "Open order delivery"
},
"shop_window": {
"name": "Shopping window"
}
}
}
}
+10 -3
View File
@@ -4,7 +4,7 @@
# next delivery, what is in the box, and the order that can still be changed.
#
# To use it, open your dashboard, choose "Edit dashboard" -> "Raw configuration
# editor", and paste this under `views:`. It only needs the five sensors the
# editor", and paste this under `views:`. It only needs the six sensors the
# integration creates.
type: sections
@@ -40,6 +40,11 @@ sections:
name: Order total
icon: mdi:cash-multiple
color: teal
- type: tile
entity: sensor.fresh_harvest_next_delivery_add_ons
name: Add-ons
icon: mdi:cart-plus
color: purple
- type: tile
entity: sensor.fresh_harvest_next_delivery_items
name: Items
@@ -55,8 +60,10 @@ sections:
content: |-
{%- set produce = state_attr('sensor.fresh_harvest_next_delivery_items', 'produce') or [] -%}
{%- set addons = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons') or [] -%}
{%- set addons_total = state_attr('sensor.fresh_harvest_next_delivery_items', 'add_ons_total') -%}
{%- set box_price = state_attr('sensor.fresh_harvest_next_delivery_items', 'box_price') -%}
{%- if produce -%}
**In the box**
**In the box**{% if box_price %} · ${{ '%.2f' | format(box_price) }}{% endif %}
{% for i in produce %}
- {{ i }}
{%- endfor %}
@@ -64,7 +71,7 @@ sections:
_Box contents not assigned yet._
{%- endif %}
{% if addons %}
**Add-ons**
**Add-ons**{% if addons_total %} · ${{ '%.2f' | format(addons_total) }}{% endif %}
{% for a in addons %}
- {{ a }}
{%- endfor %}
+36 -3
View File
@@ -70,13 +70,46 @@
</div>
</div>
</div>
<!-- Multi-quantity add-on: item-total is the EXTENDED price, 4 x $4.49. -->
<div id='DODRow-4' class='cart-item qv-button shop-item-cursor' data-qvid='9012'>
<img class='cart-item-img' src='https://cdn.freshharvest.com/y.jpg' alt='Complete Recovery Smoothie' />
<div class='item-details'>
<span class='item-name'><div>Complete Recovery Smoothie</div></span>
<span class='item-total'>$17.96</span>
<span class='item-uom'><div>15.2 fl oz</div><div class='snap-tag-cart'></div></span>
<div class='item-order-wrapper'>
<div class='item-order item-order-disabled'>
<div class='btn-round item-order-remove qv-ignore'></div>
<span class='item-order-quantity qv-ignore' data-item-quantity='9012'>4</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
<div id='DODRow-5' class='cart-item qv-button shop-item-cursor' data-qvid='9105'>
<img class='cart-item-img' src='https://cdn.freshharvest.com/z.jpg' alt='Organic Fruit Punch Juice Boxes' />
<div class='item-details'>
<span class='item-name'><div>Organic Fruit Punch Juice Boxes</div></span>
<span class='item-total'>$13.98</span>
<span class='item-uom'><div>8 count</div><div class='snap-tag-cart'></div></span>
<div class='item-order-wrapper'>
<div class='item-order item-order-disabled'>
<div class='btn-round item-order-remove qv-ignore'></div>
<span class='item-order-quantity qv-ignore' data-item-quantity='9105'>2</span>
<div class='btn-round item-order-add qv-ignore'></div>
</div>
</div>
</div>
</div>
</div>
<div id='OrderTotals-2772590'>
<div class='cart-summary'>
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$105.88</span>
<span class='summary-item label'>Tax</span><span class='summary-item value'>$3.18</span>
<!-- Self-consistent: box 33.00 + add-ons 39.93 = 72.93 subtotal, which is
over the $70 free-delivery threshold, hence the $0.00 fee. -->
<span class='summary-item label'>Subtotal</span><span class='summary-item value'>$72.93</span>
<span class='summary-item label'>Tax</span><span class='summary-item value'>$2.19</span>
<span class='summary-item label'>Delivery</span><span class='summary-item value'>$0.00</span>
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$109.06</span>
<span class='summary-item label demi-bold total'>Order Total <span>See Details</span></span><span class='summary-item value'>$75.12</span>
</div>
</div>
+20 -6
View File
@@ -70,16 +70,30 @@ def test_next_order_is_the_locked_one(snapshot):
def test_next_order_totals_and_contents(snapshot):
order = snapshot.next_order
assert order.box_name == "Georgia Grown Small Box"
assert (order.subtotal, order.tax, order.delivery_fee) == (105.88, 3.18, 0.0)
assert order.total == 109.06
assert (order.subtotal, order.tax, order.delivery_fee) == (72.93, 2.19, 0.0)
assert order.total == 75.12
assert [(i.quantity, i.name, i.unit) for i in order.items] == [
(1, "Bolero Carrots", ".5 lb"),
(2, "Georgia Peaches", "6 count"),
]
assert [
(a.name, a.price, a.quantity, a.unit) for a in order.addons
] == [("Black Mission Figs", 7.99, 1, "1 pint")]
assert len(order.all_items) == 3
assert [(a.name, a.price, a.quantity, a.unit) for a in order.addons] == [
("Black Mission Figs", 7.99, 1, "1 pint"),
("Complete Recovery Smoothie", 17.96, 4, "15.2 fl oz"),
("Organic Fruit Punch Juice Boxes", 13.98, 2, "8 count"),
]
assert len(order.all_items) == 5
def test_addons_total_reconciles_with_the_subtotal(snapshot):
"""Add-ons plus the box price must equal the subtotal the portal reports."""
order = snapshot.next_order
assert order.addons_total == 39.93
assert round(order.addons_total + order.box_price, 2) == order.subtotal
def test_addons_total_is_zero_when_nothing_is_added(snapshot):
assert snapshot.open_order.addons == []
assert snapshot.open_order.addons_total == 0.0
def test_open_order(snapshot):