Skip to main content

max / makenotwork

SyncKit v2 billing: open-platform developer billing Replace per-end-user app_sync subscriptions with a developer-pays-MNW model. Each SyncKit app's developer is billed directly via two knobs (storage_gb_cap and egress_multiple, optional key_cap in per_key mode); price is computed in src/synckit_billing.rs at 2x Hetzner Object Storage marginal cost with a pro-rated $5 floor. The end-user no longer pays anything for sync. First-party apps (GO/BB/AF) keep working via an is_internal flag that bypasses billing entirely. Phase 1: migration 117 adds the billing columns to sync_apps plus the sync_app_keys and sync_app_usage_current tables; drops app_sync_subscriptions. Phase 2-3: rip out the old end-user billing (AppSyncTier, AppSyncCheckoutParams, routes/synckit/subscription.rs) and add developer-facing routes setup/ activate/patch/cancel/get on /api/sync/apps/{id}/billing, plus a Stripe billing portal link. Webhook dispatch in routes/stripe/webhook/billing.rs routes the new subscription class by DB lookup on stripe_subscription_id. Phase 4: server-to-server SDK key claim/release/list with per_key cap enforcement returning 402 key_limit_reached. Phase 5: storage + egress byte counters on blob confirm/download presign with 402 enforcement, hourly warning email job at 75/90/100% thresholds (scheduler/synckit_warnings.rs), and a weekly storage drift recalc.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 21:49 UTC
Commit: a913484881d75fb0c79a554817f9aeaa5164c8bd
Parent: 1c3cd28
32 files changed, +2707 insertions, -602 deletions
@@ -126,7 +126,7 @@
126 126 async-stripe-billing = { version = "1.0.0-rc.5", features = ["deserialize", "subscription", "billing_portal_session"] }
127 127 async-stripe-checkout = { version = "1.0.0-rc.5", features = ["deserialize", "checkout_session"] }
128 128 async-stripe-connect = { version = "1.0.0-rc.5", features = ["deserialize", "account", "account_link"] }
129 - async-stripe-core = { version = "1.0.0-rc.5", features = ["deserialize", "balance", "refund"] }
129 + async-stripe-core = { version = "1.0.0-rc.5", features = ["deserialize", "balance", "refund", "customer"] }
130 130 async-stripe-payment = { version = "1.0.0-rc.5", features = ["deserialize"] }
131 131 async-stripe-product = { version = "1.0.0-rc.5", features = ["deserialize", "product", "price"] }
132 132 async-stripe-types = { version = "1.0.0-rc.5", features = ["deserialize"] }
@@ -24,6 +24,7 @@
24 24 pub mod wam_client;
25 25 pub mod payments;
26 26 pub mod pricing;
27 + pub mod synckit_billing;
27 28 pub mod scheduler;
28 29 pub mod routes;
29 30 pub mod rss;
@@ -10519,3 +10519,125 @@
10519 10519
10520 10520 /* Inline-form margin-left for adjacent pills (collection.html private badge). */
10521 10521 .badge--inline-l { margin-left: var(--space-2); }
10522 +
10523 + /* ===== Team page ===== */
10524 +
10525 + .team-page { max-width: 56rem; margin: 0 auto; }
10526 + .team-intro {
10527 + font-size: 1.05rem;
10528 + line-height: 1.55;
10529 + margin: 0 0 var(--space-6);
10530 + opacity: 0.85;
10531 + }
10532 + .team-section { margin-top: var(--space-8); }
10533 + .team-section + .team-section { margin-top: var(--space-7); }
10534 + .team-empty {
10535 + font-size: 0.95rem;
10536 + opacity: 0.65;
10537 + font-style: italic;
10538 + }
10539 + .team-card-experience {
10540 + margin: var(--space-3) 0 var(--space-2);
10541 + line-height: 1.5;
10542 + }
10543 + .team-card-links {
10544 + margin: var(--space-2) 0;
10545 + font-size: 0.95rem;
10546 + }
10547 + .team-card-link { font-weight: 500; }
10548 + .team-card-bio {
10549 + margin-top: var(--space-3);
10550 + border-top: 1px solid var(--color-border);
10551 + padding-top: var(--space-3);
10552 + }
10553 + .team-card-bio > summary {
10554 + cursor: pointer;
10555 + font-weight: 500;
10556 + font-size: 0.95rem;
10557 + list-style: none;
10558 + }
10559 + .team-card-bio > summary::-webkit-details-marker { display: none; }
10560 + .team-card-bio > summary::before {
10561 + content: "+ ";
10562 + display: inline-block;
10563 + width: 1.2em;
10564 + opacity: 0.6;
10565 + }
10566 + .team-card-bio[open] > summary::before { content: "− "; }
10567 + .team-card-bio > p {
10568 + margin-top: var(--space-3);
10569 + line-height: 1.6;
10570 + }
10571 +
10572 + /* ── SyncKit billing panel ──
10573 + Per-app accordion shown inside the SyncKit dashboard tab (both user and
10574 + project levels). The .synckit-billing root carries pricing constants as
10575 + data-attrs that static/synckit-billing.js reads to mirror monthly_price_cents.
10576 + */
10577 + .synckit-billing {
10578 + background: var(--surface-raised);
10579 + border: 1px solid var(--border);
10580 + border-radius: 3px;
10581 + padding: var(--space-2) var(--space-3);
10582 + min-width: 220px;
10583 + }
10584 + .synckit-billing-summary {
10585 + cursor: pointer;
10586 + display: flex;
10587 + align-items: center;
10588 + gap: var(--space-3);
10589 + font-size: 0.875rem;
10590 + }
10591 + .synckit-billing-toggle { margin-left: auto; color: var(--text-muted, #777); }
10592 + .synckit-billing-status {
10593 + display: inline-block;
10594 + padding: 2px var(--space-2);
10595 + border-radius: 2px;
10596 + font-size: 0.75rem;
10597 + font-weight: 600;
10598 + }
10599 + .synckit-billing-status--draft { background: var(--surface-muted); }
10600 + .synckit-billing-status--active { background: var(--success-bg); color: var(--success); }
10601 + .synckit-billing-status--suspended_unpaid { background: var(--warning-bg); color: var(--warning); }
10602 + .synckit-billing-status--canceled { background: var(--danger-bg); color: var(--danger); }
10603 + .synckit-billing-price { font-weight: 600; }
10604 + .synckit-billing-body {
10605 + padding-top: var(--space-3);
10606 + display: flex;
10607 + flex-direction: column;
10608 + gap: var(--space-4);
10609 + }
10610 + .synckit-billing-section { display: flex; flex-direction: column; gap: var(--space-2); }
10611 + .synckit-billing-subheading { font-size: 0.875rem; margin: 0; font-weight: 600; }
10612 + .synckit-knob-row {
10613 + display: flex;
10614 + align-items: center;
10615 + gap: var(--space-2);
10616 + flex-wrap: wrap;
10617 + }
10618 + .synckit-knob-row label { min-width: 110px; }
10619 + .synckit-knob-row input[type="range"] { flex: 1 1 160px; min-width: 120px; }
10620 + .synckit-radio { display: inline-flex; align-items: center; gap: var(--space-1); min-width: 0; }
10621 + .synckit-billing-summary-line {
10622 + display: flex;
10623 + align-items: baseline;
10624 + gap: var(--space-2);
10625 + padding-top: var(--space-2);
10626 + border-top: 1px solid var(--border);
10627 + }
10628 + .synckit-price-preview { font-size: 1.125rem; font-weight: 600; }
10629 + .synckit-billing-actions { display: flex; gap: var(--space-2); }
10630 + .synckit-billing-manage { flex-direction: row; gap: var(--space-2); }
10631 + .synckit-billing-status-msg { font-size: 0.875rem; min-height: 1.25em; }
10632 + .synckit-billing-status-msg--info { color: var(--text-muted, #777); }
10633 + .synckit-billing-status-msg--error { color: var(--danger); }
10634 + .synckit-billing-period { margin: 0; }
10635 + .synckit-gauge { display: flex; flex-direction: column; gap: 2px; }
10636 + .synckit-gauge-label {
10637 + display: flex;
10638 + justify-content: space-between;
10639 + font-size: 0.8125rem;
10640 + }
10641 + .synckit-gauge-value { color: var(--text-muted, #777); }
10642 + .synckit-gauge-fill--warn { background: var(--warning); }
10643 + .synckit-gauge-fill--danger { background: var(--danger); }
@@ -31,6 +31,7 @@
31 31
32 32 <script src="/static/mnw.js?v=0514"></script>
33 33 <script src="/static/collections.js?v=0514"></script>
34 + <script src="/static/synckit-billing.js?v=0620"></script>
34 35 {% block scripts %}{% endblock %}
35 36 </body>
36 37 </html>
@@ -552,95 +552,6 @@
552 552 }
553 553 }
554 554
555 - // ── App Sync Tiers ──
556 -
557 - /// Subscription tier for app-level cloud sync (GO, BB, AF).
558 - /// GO and BB use `Standard` (single tier). AF uses Light/Standard/Large for blob storage.
559 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
560 - #[serde(rename_all = "snake_case")]
561 - pub enum AppSyncTier {
562 - /// GO/BB single tier, or AF metadata-only (no blob storage)
563 - Standard,
564 - /// AF blob: 10 GB
565 - Light,
566 - /// AF blob: 50 GB (also the default for GO/BB)
567 - Large,
568 - }
569 -
570 - impl_str_enum!(AppSyncTier {
571 - Standard => "standard",
572 - Light => "light",
573 - Large => "large",
574 - });
575 -
576 - impl AppSyncTier {
577 - /// Human-readable label for display.
578 - pub fn label(&self) -> &'static str {
579 - match self {
580 - Self::Standard => "Standard",
581 - Self::Light => "Light",
582 - Self::Large => "Large",
583 - }
584 - }
585 -
586 - /// Blob storage limit in bytes for this tier.
587 - pub fn blob_storage_bytes(&self) -> Option<i64> {
588 - match self {
589 - Self::Light => Some(10 * 1024 * 1024 * 1024), // 10 GB
590 - Self::Standard => Some(50 * 1024 * 1024 * 1024), // 50 GB
591 - Self::Large => Some(200 * 1024 * 1024 * 1024), // 200 GB
592 - }
593 - }
594 -
595 - /// Monthly price in cents for a given app. Returns None if tier is not valid for the app.
596 - pub fn monthly_price_cents(&self, app_name: &str) -> Option<i64> {
597 - match app_name.to_lowercase().as_str() {
598 - "goingson" => match self {
599 - Self::Standard => Some(200), // $2/mo
600 - _ => None,
601 - },
602 - "balanced_breakfast" | "balanced breakfast" => match self {
603 - Self::Standard => Some(100), // $1/mo
604 - _ => None,
605 - },
606 - "audiofiles" => match self {
607 - Self::Light => Some(100), // $1/mo
608 - Self::Standard => Some(300), // $3/mo
609 - Self::Large => Some(800), // $8/mo
610 - },
611 - _ => None,
612 - }
613 - }
614 -
615 - /// Annual price in cents for a given app. Returns None if tier is not valid for the app.
616 - pub fn annual_price_cents(&self, app_name: &str) -> Option<i64> {
617 - match app_name.to_lowercase().as_str() {
618 - "goingson" => match self {
619 - Self::Standard => Some(1500), // $15/yr
620 - _ => None,
621 - },
622 - "balanced_breakfast" | "balanced breakfast" => match self {
623 - Self::Standard => Some(800), // $8/yr
624 - _ => None,
625 - },
626 - "audiofiles" => match self {
627 - Self::Light => Some(1000), // $10/yr
628 - Self::Standard => Some(3000), // $30/yr
629 - Self::Large => Some(8000), // $80/yr
630 - },
631 - _ => None,
632 - }
633 - }
634 -
635 - /// Product name for Stripe checkout display.
636 - pub fn product_name(&self, app_name: &str) -> String {
637 - match app_name.to_lowercase().as_str() {
638 - "audiofiles" => format!("audiofiles Cloud Sync — {}", self.label()),
639 - _ => format!("{app_name} Cloud Sync"),
640 - }
641 - }
642 - }
643 -
644 555 // ── AI Tiers ──
645 556
646 557 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -1032,7 +943,6 @@
1032 943 FanPlus,
1033 944 CreatorTier,
1034 945 Cart,
1035 - AppSync,
1036 946 }
1037 947
1038 948 impl_str_enum!(CheckoutType {
@@ -1042,7 +952,6 @@
1042 952 FanPlus => "fan_plus",
1043 953 CreatorTier => "creator_tier",
1044 954 Cart => "cart",
1045 - AppSync => "app_sync",
1046 955 });
1047 956
1048 957 impl ModerationActionType {
@@ -1254,27 +1163,6 @@
1254 1163 assert_eq!(CreatorTier::Everything.features(), &["file_uploads", "large_files"]);
1255 1164 }
1256 1165
1257 - #[test]
1258 - fn app_sync_tier_round_trip() {
1259 - assert_eq!(AppSyncTier::Standard.to_string(), "standard");
1260 - assert_eq!("light".parse::<AppSyncTier>().unwrap(), AppSyncTier::Light);
1261 - assert_eq!("large".parse::<AppSyncTier>().unwrap(), AppSyncTier::Large);
1262 - assert!("bogus".parse::<AppSyncTier>().is_err());
1263 - }
1264 -
1265 - #[test]
1266 - fn app_sync_tier_storage() {
1267 - assert_eq!(AppSyncTier::Light.blob_storage_bytes(), Some(10 * 1024 * 1024 * 1024));
1268 - assert_eq!(AppSyncTier::Standard.blob_storage_bytes(), Some(50 * 1024 * 1024 * 1024));
1269 - assert_eq!(AppSyncTier::Large.blob_storage_bytes(), Some(200 * 1024 * 1024 * 1024));
1270 - }
1271 -
1272 - #[test]
1273 - fn checkout_type_app_sync() {
1274 - assert_eq!(CheckoutType::AppSync.to_string(), "app_sync");
1275 - assert_eq!("app_sync".parse::<CheckoutType>().unwrap(), CheckoutType::AppSync);
1276 - }
1277 -
1278 1166 #[test]
1279 1167 fn project_feature_round_trip() {
1280 1168 assert_eq!(ProjectFeature::Audio.to_string(), "audio");
@@ -23,6 +23,7 @@
23 23 pub(crate) mod blog_posts;
24 24 pub(crate) mod license_keys;
25 25 pub(crate) mod synckit;
26 + pub(crate) mod synckit_billing;
26 27 pub(crate) mod oauth;
27 28 pub(crate) mod promo_codes;
28 29 pub(crate) mod follows;
@@ -68,7 +69,6 @@
68 69 pub(crate) mod page_views;
69 70 pub(crate) mod pending_s3_deletions;
70 71 pub(crate) mod pending_uploads;
71 - pub(crate) mod app_sync;
72 72
73 73 pub use id_types::*;
74 74 pub use validated_types::*;
@@ -80,13 +80,12 @@
80 80
81 81 /// Check the sandbox per-IP cap under an advisory lock on a single connection.
82 82 ///
83 - /// Acquires a session-level advisory lock, runs the count query, and unlocks —
84 - /// all on the same connection. Returns the active sandbox count.
83 + /// Acquires a session-level advisory lock, runs the count query, and unlocks; /// all on the same connection. Returns the active sandbox count.
85 84 ///
86 85 /// This avoids the bug where `advisory_lock` + `advisory_unlock` through a pool
87 86 /// use different connections, leaving locks permanently held.
88 87 ///
89 - /// Uses `pg_try_advisory_lock` to avoid blocking under burst load — if the lock
88 + /// Uses `pg_try_advisory_lock` to avoid blocking under burst load; if the lock
90 89 /// is already held, returns an error rather than waiting.
91 90 pub async fn check_sandbox_cap(pool: &PgPool, lock_key: i64, ip: &str) -> Result<i64> {
92 91 let mut conn = pool.acquire().await.map_err(|e| {