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) |