| 1 |
{# SyncKit billing panel — included per-app from user_synckit.html and project_synckit.html. |
| 2 |
|
| 3 |
Parent context must expose `app: SyncAppRow` with `app.billing: Option<SyncAppBillingView>`. |
| 4 |
|
| 5 |
Two pricing modes (driven by the `enforcement_mode` radio): |
| 6 |
bulk — developer sets storage_gb_cap. Price = storage_gb_cap × rate. |
| 7 |
per_key — developer sets key_cap + gb_per_key. Price = key_cap × gb_per_key × rate. |
| 8 |
|
| 9 |
Pricing-formula constants live on data-* attrs on the .synckit-billing root |
| 10 |
so static/synckit-billing.js can mirror monthly_price_cents() without |
| 11 |
duplicating values. They must stay in sync with src/synckit_billing.rs. |
| 12 |
#} |
| 13 |
{% if let Some(b) = app.billing %} |
| 14 |
<details class="synckit-billing" data-app-id="{{ app.id }}" |
| 15 |
data-status="{{ b.status }}" |
| 16 |
data-is-internal="{{ b.is_internal }}" |
| 17 |
data-has-customer="{{ b.has_customer }}" |
| 18 |
data-enforcement-mode="{{ b.enforcement_mode }}" |
| 19 |
data-storage-gb="{{ b.default_storage_gb }}" |
| 20 |
data-key-cap="{{ b.default_key_cap }}" |
| 21 |
data-gb-per-key="{{ b.default_gb_per_key }}" |
| 22 |
data-storage-rate="3" |
| 23 |
data-base-floor-cents="31"> |
| 24 |
<summary class="synckit-billing-summary"> |
| 25 |
<span class="synckit-billing-status synckit-billing-status--{{ b.status }}"> |
| 26 |
{% if b.is_internal %}Internal{% else if b.status == "draft" %}Draft{% else if b.status == "active" %}Active{% else if b.status == "suspended_unpaid" %}Past due{% else if b.status == "canceled" %}Canceled{% else %}{{ b.status }}{% endif %} |
| 27 |
</span> |
| 28 |
{% if !b.price_display.is_empty() %} |
| 29 |
<span class="synckit-billing-price">{{ b.price_display }}</span> |
| 30 |
{% endif %} |
| 31 |
<span class="synckit-billing-toggle">Billing</span> |
| 32 |
</summary> |
| 33 |
|
| 34 |
<div class="synckit-billing-body"> |
| 35 |
|
| 36 |
{% if b.is_internal %} |
| 37 |
<p class="form-hint">First-party app — billing is not collected.</p> |
| 38 |
|
| 39 |
{% else %} |
| 40 |
{# Setup step: no Stripe customer yet → show "Set up billing" button. #} |
| 41 |
{% if !b.has_customer %} |
| 42 |
<div class="synckit-billing-section"> |
| 43 |
<p class="form-hint">Set up a payment method to enable Cloud Sync billing for this app.</p> |
| 44 |
<button type="button" class="btn synckit-billing-setup-btn">Set up billing</button> |
| 45 |
<div class="synckit-billing-status-msg"></div> |
| 46 |
</div> |
| 47 |
{% endif %} |
| 48 |
|
| 49 |
{# Knob picker: shown for has_customer + draft/active. Canceled apps see a Reactivate hint. #} |
| 50 |
{% if b.has_customer && b.status != "canceled" %} |
| 51 |
<form class="synckit-billing-form synckit-billing-section"> |
| 52 |
<div class="synckit-knob-row"> |
| 53 |
<label>Pricing mode</label> |
| 54 |
<label class="synckit-radio"> |
| 55 |
<input type="radio" name="synckit-mode-{{ app.id }}" value="bulk" |
| 56 |
{% if b.enforcement_mode == "bulk" %}checked{% endif %}> |
| 57 |
Bulk |
| 58 |
</label> |
| 59 |
<label class="synckit-radio"> |
| 60 |
<input type="radio" name="synckit-mode-{{ app.id }}" value="per_key" |
| 61 |
{% if b.enforcement_mode == "per_key" %}checked{% endif %}> |
| 62 |
Per-key |
| 63 |
</label> |
| 64 |
</div> |
| 65 |
|
| 66 |
<div class="synckit-knob-row synckit-bulk-row" |
| 67 |
{% if b.enforcement_mode != "bulk" %}hidden{% endif %}> |
| 68 |
<label for="synckit-storage-{{ app.id }}">Storage</label> |
| 69 |
<input type="range" id="synckit-storage-{{ app.id }}" |
| 70 |
class="synckit-storage-slider" min="1" max="10240" step="1" |
| 71 |
value="{{ b.default_storage_gb }}"> |
| 72 |
<input type="number" class="synckit-storage-input input--sm w-100" |
| 73 |
min="1" step="1" value="{{ b.default_storage_gb }}"> |
| 74 |
<span class="form-hint">GB total</span> |
| 75 |
</div> |
| 76 |
|
| 77 |
<div class="synckit-knob-row synckit-per-key-row" |
| 78 |
{% if b.enforcement_mode != "per_key" %}hidden{% endif %}> |
| 79 |
<label>Key cap</label> |
| 80 |
<input type="number" class="synckit-key-cap-input input--sm w-100" |
| 81 |
min="1" step="1" value="{{ b.default_key_cap }}"> |
| 82 |
<span class="form-hint">max active keys</span> |
| 83 |
</div> |
| 84 |
|
| 85 |
<div class="synckit-knob-row synckit-per-key-row" |
| 86 |
{% if b.enforcement_mode != "per_key" %}hidden{% endif %}> |
| 87 |
<label>GB per key</label> |
| 88 |
<input type="number" class="synckit-gb-per-key-input input--sm w-100" |
| 89 |
min="1" step="1" value="{{ b.default_gb_per_key }}"> |
| 90 |
<span class="form-hint">storage allotment per key</span> |
| 91 |
</div> |
| 92 |
|
| 93 |
<div class="synckit-billing-summary-line"> |
| 94 |
<span class="synckit-price-preview">$0.31</span> |
| 95 |
<span class="form-hint">/ month — $0.03/GB, floored at $0.31 (Stripe fee)</span> |
| 96 |
</div> |
| 97 |
|
| 98 |
<div class="synckit-billing-actions"> |
| 99 |
{% if b.status == "draft" %} |
| 100 |
<button type="button" class="btn synckit-billing-activate-btn">Activate billing</button> |
| 101 |
{% else %} |
| 102 |
<button type="button" class="btn synckit-billing-save-btn" disabled>Save changes</button> |
| 103 |
{% endif %} |
| 104 |
</div> |
| 105 |
<div class="synckit-billing-status-msg"></div> |
| 106 |
</form> |
| 107 |
{% endif %} |
| 108 |
|
| 109 |
{# Usage gauges (storage enforced; egress and keys shown as stats). #} |
| 110 |
{% if b.status == "active" || b.status == "suspended_unpaid" %} |
| 111 |
<div class="synckit-billing-section"> |
| 112 |
<h3 class="synckit-billing-subheading">Usage this period</h3> |
| 113 |
|
| 114 |
<div class="synckit-gauge"> |
| 115 |
<div class="synckit-gauge-label"> |
| 116 |
<span>Storage</span> |
| 117 |
<span class="synckit-gauge-value">{{ b.storage_display }}</span> |
| 118 |
</div> |
| 119 |
<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded"> |
| 120 |
<div class="progress-bar synckit-gauge-fill{% if !b.storage_tier.is_empty() %} synckit-gauge-fill--{{ b.storage_tier }}{% endif %}" |
| 121 |
style="width: {{ b.storage_pct }}%"></div> |
| 122 |
</div> |
| 123 |
</div> |
| 124 |
|
| 125 |
<div class="synckit-stat"> |
| 126 |
<span>Egress</span> |
| 127 |
<span class="synckit-gauge-value">{{ b.egress_display }}</span> |
| 128 |
</div> |
| 129 |
|
| 130 |
{% if b.enforcement_mode == "per_key" %} |
| 131 |
<div class="synckit-gauge"> |
| 132 |
<div class="synckit-gauge-label"> |
| 133 |
<span>Keys</span> |
| 134 |
<span class="synckit-gauge-value">{{ b.keys_claimed }}{% if let Some(c) = b.key_cap %} / {{ c }}{% endif %}</span> |
| 135 |
</div> |
| 136 |
<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded"> |
| 137 |
<div class="progress-bar synckit-gauge-fill{% if !b.keys_tier.is_empty() %} synckit-gauge-fill--{{ b.keys_tier }}{% endif %}" |
| 138 |
style="width: {{ b.keys_pct }}%"></div> |
| 139 |
</div> |
| 140 |
</div> |
| 141 |
|
| 142 |
{% if !b.top_keys.is_empty() %} |
| 143 |
<div class="synckit-key-gauges"> |
| 144 |
<div class="synckit-billing-subheading-row"> |
| 145 |
<span class="synckit-billing-subheading">Storage by key</span> |
| 146 |
<span class="form-hint">top {{ b.top_keys.len() }} by usage</span> |
| 147 |
</div> |
| 148 |
{% for k in b.top_keys %} |
| 149 |
<div class="synckit-gauge synckit-gauge--key"> |
| 150 |
<div class="synckit-gauge-label"> |
| 151 |
<span class="synckit-key-name" title="{{ k.key }}">{{ k.key }}</span> |
| 152 |
<span class="synckit-gauge-value">{{ k.display }}</span> |
| 153 |
</div> |
| 154 |
<div class="progress-bar-container progress-bar-container--slim progress-bar-container--rounded"> |
| 155 |
<div class="progress-bar synckit-gauge-fill{% if !k.tier.is_empty() %} synckit-gauge-fill--{{ k.tier }}{% endif %}" |
| 156 |
style="width: {{ k.pct }}%"></div> |
| 157 |
</div> |
| 158 |
</div> |
| 159 |
{% endfor %} |
| 160 |
{% if b.more_keys %} |
| 161 |
<p class="form-hint">More keys not shown. Fetch the full list via <code>POST /api/sync/keys/list</code>.</p> |
| 162 |
{% endif %} |
| 163 |
</div> |
| 164 |
{% endif %} |
| 165 |
{% endif %} |
| 166 |
|
| 167 |
{% if !b.period_end_display.is_empty() %} |
| 168 |
<p class="form-hint synckit-billing-period">{{ b.period_end_display }}</p> |
| 169 |
{% endif %} |
| 170 |
</div> |
| 171 |
{% endif %} |
| 172 |
|
| 173 |
{# Manage / cancel actions: shown for active and past-due. #} |
| 174 |
{% if b.status == "active" || b.status == "suspended_unpaid" %} |
| 175 |
<div class="synckit-billing-section synckit-billing-manage"> |
| 176 |
<button type="button" class="btn-small btn-secondary synckit-billing-portal-btn">Manage payment method</button> |
| 177 |
<button type="button" class="btn-small btn-danger synckit-billing-cancel-btn">Cancel billing</button> |
| 178 |
</div> |
| 179 |
{% endif %} |
| 180 |
|
| 181 |
{% if b.status == "canceled" %} |
| 182 |
<p class="form-hint">Billing for this app is canceled. Recreate the app or contact support to reactivate.</p> |
| 183 |
{% endif %} |
| 184 |
{% endif %} |
| 185 |
|
| 186 |
</div> |
| 187 |
</details> |
| 188 |
{% endif %} |
| 189 |
|