max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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| { |