Org Web Adapter

hungryroot/brand/tdd-notify-me-pairings-support.org

TDD: Pairing Back-In-Stock Notifications

#+AUTHOR: Colin Powell

#+DATE: 2026-07-16

Overview

Extend the existing single-product "Notify Me" back-in-stock feature so a

customer can subscribe to a *pairing* (a curated combo of multiple products)

and be notified only once *every* product in that pairing is back in stock.

The current feature lives in the =inventory_notifications= app. A customer

subscribes to one =Product=; a Celery poll fires a Braze event when that product

re-enters the customer's next-order in-stock set. This TDD adds pairing-level

subscriptions: fire when *all* ingredients of a =Pairing= are simultaneously in

stock for the customer's upcoming order.

*Status:* DRAFT

Problem

Customers want a full pairing (recipe combo), not just a single ingredient. When

any product in a pairing is out of stock the pairing is uncustomizable. Today a

customer can only subscribe to individual products, so to be alerted about a

pairing they must subscribe to each ingredient separately and manually reconcile

N notifications. We need to:

- Let a customer subscribe to a *pairing* in one action.

- Fire a single notification only when *all* ingredients are in stock together

for the customer's next-order inventory date + origin.

- Reuse the existing poll, one-shot, SMS-consent, and allowed-hours machinery —

no new cron, no new delivery channel.

- Keep single-product subscriptions unchanged and backward compatible.

People

| Role | Name |

|------------------+--------------|

| Technical Author | Colin Powell |

| Jira | TBD |

Design

Subscription Model

Extend =CustomerInventoryNotification= (=inventory_notifications/models.py:7=)

to optionally target a pairing instead of a product. Exactly one of

=product= / =pairing= is set per row.

| Field | Change |

|-------------------+-----------------------------------------------------------------|

| =product= | Make nullable (was required FK to =app.Product=) |

| =pairing= | New nullable FK to =app.Pairing=, related_name inventory_notifications |

| CHECK constraint | Exactly one of =product=, =pairing= is non-null |

Everything else (=customer=, =notification_type=, =is_active=, =notified_at=,

=create_date=) is unchanged and shared by both subscription kinds.

Detection Rule

The existing service (=inventory_notifications/services.py:27=

=InventoryNotificationsService=) already computes a per-customer in-stock set

for the customer's next-order =(inventory_date, origin_id)= via

=_get_in_stock_products()= (merchandized products minus

=Product.get_unavailable_ids()=), cached per key.

| Subscription kind | Fires when |

|-------------------+------------------------------------------------------------------|

| Product | product_id in in-stock set (unchanged) |

| Pairing | EVERY ingredient of the pairing is in the in-stock set |

Pairing ingredients come from =pairing.needed_ingredients= (the

=PairingIngredient= through rows, =app/models/pairing_ingredient.py:8=) /

=pairing.ingredients= M2M. This is a set-membership test against the

already-cached in-stock set — no extra inventory queries beyond loading the

ingredient id list.

*Note on reuse:* =app/services/ops/stock_status_service.py=

(=_check_pairing_stock=) already computes an all-ingredients-in-stock aggregate,

but via the =Inventory= model per origin/date. The notification service uses the

merchandized-set path. We reuse that same in-stock set for consistency with what

the customer would actually be able to customize — we do *not* call

=stock_status_service=.

Braze Event

Add a new Braze event =PAIRING_IN_STOCK= (constant in =app/constants/braze.py=,

alongside =PRODUCT_IN_STOCK= at line 64) rather than overloading

=PRODUCT_IN_STOCK=, so CRM can segment/template pairing alerts independently.

Payload: =customer_id=, =order_id=, =pairing_id=, =pairing_name=,

=notification_type=, =pairing_url=, =card_image_url=, =ingredient_count=.

Alternatives Considered

| Approach | Rejected Because |

|---------------------------------------------+----------------------------------------------------------------------------------|

| N product-level rows grouped logically | One-shot fire per group is fragile; partial restock churn; no clean dedupe |

| New =PairingInventoryNotification= model | Duplicates customer/type/is_active/notified_at plumbing; two poll paths to maintain |

| Reuse =stock_status_service._check_pairing_stock= | Different stock source (Inventory rows) than notify path; risk of divergent "in stock" answers |

| Reuse =PRODUCT_IN_STOCK= Braze event | CRM cannot segment pairing vs product alerts; different template/copy needed |

| Fire per-ingredient as each restocks | Not the ask — customer wants the whole pairing available, not partial alerts |

Implementation

Files Changed

| File | Change |

|-----------------------------------------------------+------------------------------------------------------------------------|

| =inventory_notifications/models.py= | =product= nullable; add nullable =pairing= FK; CHECK constraint (one-of) |

| =inventory_notifications/migrations/= | New migration: alter =product=, add =pairing=, add constraint |

| =inventory_notifications/services.py= | Branch in send path: pairing → all-ingredients-in-set test; Braze payload builder for pairing |

| =app/constants/braze.py= | Add =PAIRING_IN_STOCK= event constant |

| =inventory_notifications/const.py= | Extend =BRAZE_NOTIFICATION_TYPE_MAP= usage if type mapping needed |

| =inventory_notifications/serializers.py= | Accept =pairing= (PK) XOR =product=; validate active pairing; one-of validation |

| =inventory_notifications/views.py= | =perform_create= dedupe on customer+pairing (mirror customer+product) |

| =inventory_notifications/tests/test_services.py= | Pairing fires only when all ingredients in stock; partial does not fire |

| =inventory_notifications/tests/test_views.py= | Create pairing subscription; one-of validation; dedupe |

| =inventory_notifications/tests/factories.py= | Pairing-subscription factory variant |

Service Branch (services.py, sketch)

#+begin_src python

def _is_subscription_in_stock(self, notification, in_stock_ids):

if notification.pairing_id:

ingredient_ids = notification.pairing.ingredients.values_list("id", flat=True)

return bool(ingredient_ids) and all(pid in in_stock_ids for pid in ingredient_ids)

return notification.product_id in in_stock_ids

#+end_src

The =send_notification= / =process_notifications= loop is otherwise unchanged:

same allowed-hour gate, same SMS transactional consent check, same one-shot

=is_active=False= + =notified_at= stamp on success, same retry-on-Braze-failure.

Rollout

- [ ] Merge model migration (nullable =pairing=, one-of constraint — no behavior change for existing product rows)

- [ ] Merge service + serializer + Braze event (gated by existing =ENABLE_INVENTORY_NOTIFICATIONS=, default off)

- [ ] CRM: build =PAIRING_IN_STOCK= Braze campaign + template before enabling

- [ ] Frontend (separate ticket): "Notify Me" button on pairing detail hitting the nested endpoint with =pairing= PK

- [ ] Enable in QA; verify all-in-stock fires once, partial restock does not fire

- [ ] Monitor Braze event delivery + one-shot dedupe in prod after enable

Risks

| Risk | Mitigation |

|-------------------------------------------------------------+----------------------------------------------------------------------------|

| Partial restock never converges (ingredients restock at different times) | Intended: fires only on a poll where all ingredients are in the set together. Document expected latency (poll = every 30 min). |

| Empty / inactive pairing (no ingredients) always "in stock" | Guard: require non-empty ingredient list before firing (see sketch) |

| Customer opted out of transactional SMS silently deactivates | Same existing behavior as product path (=is_active=False=, no send). Confirm acceptable for pairing. |

| No upcoming order → never fires | Same as today; requires a next-order inventory_date/origin to evaluate stock |

| Reusing wrong in-stock source diverges from customization UI | Use =_get_in_stock_products()= (merchandized set), matching existing product path, not =stock_status_service= |

| Ingredient-list query per notification adds load | Batch/prefetch =pairing.ingredients=; in-stock set already cached per (inventory_date, origin) |