Add shop, POS sync, event ticketing, and shipping catalog apps (#6)

## Summary
- Closes #5
- Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags
- Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop
- Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping

## Test plan
- [ ] `manage.py test` (152 passed locally)
- [ ] Shop cart + paid order decrements stocked inventory
- [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve
- [ ] Event capacity blocks overbook; paid order emails ticket codes
- [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders
- [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
westfarn committed 2026-09-06 14:00:22 -07:00
1 parent 97b8607bf2
commit 9cdce7a897
82 files changed
+3908 -1

No files matched your search

@@ -0,0 +1,25 @@
{% extends "portal_base.html" %}
{% block title %}{{ shipment.order.number }} · Shipping{% endblock %}
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
{% block portal_content %}
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
{% if shipment.tracking_number %}<p>Tracking: {{ shipment.tracking_number }}</p>{% endif %}
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
{% if shipment.status != 'labeled' %}
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
{% csrf_token %}
{% for rate in shipment.rates %}
<label>
<input type="radio" name="rate_id" value="{{ rate.id }}" {% if forloop.first %}checked{% endif %}>
{{ rate.carrier }} {{ rate.service }} — {{ rate.rate }} {{ rate.currency }}
</label><br>
{% empty %}
<p>No rates yet.</p>
{% endfor %}
{% if shipment.rates %}
<button class="btn btn-primary" type="submit">Buy label</button>
{% endif %}
</form>
{% endif %}
<p><a href="{% url 'shipping:shipment_list' %}">← All shipments</a></p>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "portal_base.html" %}
{% block title %}Shipping · Portal{% endblock %}
{% block topbar_title %}Shipping{% endblock %}
{% block portal_content %}
<p><a class="btn btn-ghost" href="{% url 'shipping:pirate_ship_export' %}">Pirate Ship CSV</a></p>
<h2>Unshipped paid orders</h2>
<table class="table">
<thead><tr><th>Order</th><th>Email</th><th></th></tr></thead>
<tbody>
{% for order in unshipped %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.email }}</td>
<td>
<form method="post" action="{% url 'shipping:shipment_create' %}">
{% csrf_token %}
<input type="hidden" name="order" value="{{ order.pk }}">
<input name="weight_oz" type="number" min="1" value="16" style="width:5rem"> oz
<button class="btn btn-primary" type="submit">Quote rates</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No paid orders waiting to ship.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Shipments</h2>
<table class="table">
<thead><tr><th>Order</th><th>Status</th><th>Tracking</th></tr></thead>
<tbody>
{% for shipment in shipments %}
<tr>
<td><a href="{% url 'shipping:shipment_detail' shipment.pk %}">{{ shipment.order.number }}</a></td>
<td>{{ shipment.get_status_display }}</td>
<td>{{ shipment.tracking_number|default:"—" }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No shipments yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}