diff --git a/Cargo.lock b/Cargo.lock index 06c3e01571..272218ec6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -639,6 +639,7 @@ dependencies = [ name = "api-subscription" version = "0.1.0" dependencies = [ + "analytics", "api-auth", "api-env", "async-stripe", @@ -4546,6 +4547,7 @@ version = "0.0.0" dependencies = [ "host", "intercept", + "pico-args", "ractor", "sentry", "serde", diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 679f6f7b12..1ab6f3e375 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -40,7 +40,7 @@ async fn app() -> Router { hypr_llm_proxy::LlmProxyConfig::new(&env.llm).with_analytics(analytics.clone()); let stt_config = hypr_transcribe_proxy::SttProxyConfig::new(&env.stt, &env.supabase) .with_hyprnote_routing(hypr_transcribe_proxy::HyprnoteRoutingConfig::default()) - .with_analytics(analytics); + .with_analytics(analytics.clone()); let stt_rate_limit = rate_limit::RateLimitState::builder() .pro( @@ -79,7 +79,8 @@ async fn app() -> Router { ); let nango_connection_state = hypr_api_nango::NangoConnectionState::from_config(&nango_config); let subscription_config = - hypr_api_subscription::SubscriptionConfig::new(&env.supabase, &env.stripe); + hypr_api_subscription::SubscriptionConfig::new(&env.supabase, &env.stripe) + .with_analytics(analytics.clone()); let support_config = hypr_api_support::SupportConfig::new( &env.github_app, &env.llm, diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e7caa1cb66..dbfd4531f7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -96,6 +96,7 @@ tracing = { workspace = true } hypr-host = { workspace = true } hypr-supervisor = { workspace = true } +pico-args = { workspace = true } ractor = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index c155f0eeac..ea6156922a 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react"; import { startTrial } from "@hypr/api-client"; import { createClient } from "@hypr/api-client/client"; -import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { useAuth } from "../../auth"; import { useBillingAccess } from "../../billing"; @@ -120,13 +119,6 @@ export function LoginSection({ onContinue }: { onContinue: () => void }) { if (store) configureProSettings(store); - void analyticsCommands.event({ event: "trial_started", plan: "pro" }); - const trialEndDate = new Date(); - trialEndDate.setDate(trialEndDate.getDate() + 14); - void analyticsCommands.setProperties({ - set: { plan: "pro", trial_end_date: trialEndDate.toISOString() }, - }); - setTrialPhase({ step: "done", result: "started" }); await auth.refreshSession(); await new Promise((r) => setTimeout(r, 3000)); diff --git a/apps/desktop/src/components/settings/general/account.tsx b/apps/desktop/src/components/settings/general/account.tsx index 1490dd6d52..e9600a1623 100644 --- a/apps/desktop/src/components/settings/general/account.tsx +++ b/apps/desktop/src/components/settings/general/account.tsx @@ -340,20 +340,6 @@ function BillingButton() { await new Promise((resolve) => setTimeout(resolve, 3000)); }, onSuccess: async () => { - void analyticsCommands.event({ - event: "trial_started", - plan: "pro", - }); - const trialEndDate = new Date(); - trialEndDate.setDate(trialEndDate.getDate() + 14); - void analyticsCommands.setProperties({ - email: auth?.session?.user.email, - user_id: auth?.session?.user.id, - set: { - plan: "pro", - trial_end_date: trialEndDate.toISOString(), - }, - }); if (store) { configureProSettings(store); } diff --git a/crates/analytics/src/lib.rs b/crates/analytics/src/lib.rs index dff63df194..6329a05941 100644 --- a/crates/analytics/src/lib.rs +++ b/crates/analytics/src/lib.rs @@ -131,6 +131,14 @@ impl AnalyticsClient { } } +pub trait ToAnalyticsPayload { + fn to_analytics_payload(&self) -> AnalyticsPayload; + + fn to_analytics_properties(&self) -> Option { + None + } +} + #[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)] pub struct AnalyticsPayload { pub event: String, @@ -150,6 +158,39 @@ pub struct PropertiesPayload { pub user_id: Option, } +#[derive(Default)] +pub struct PropertiesPayloadBuilder { + set: HashMap, + set_once: HashMap, +} + +impl PropertiesPayload { + pub fn builder() -> PropertiesPayloadBuilder { + PropertiesPayloadBuilder::default() + } +} + +impl PropertiesPayloadBuilder { + pub fn set(mut self, key: impl Into, value: impl Into) -> Self { + self.set.insert(key.into(), value.into()); + self + } + + pub fn set_once(mut self, key: impl Into, value: impl Into) -> Self { + self.set_once.insert(key.into(), value.into()); + self + } + + pub fn build(self) -> PropertiesPayload { + PropertiesPayload { + set: self.set, + set_once: self.set_once, + email: None, + user_id: None, + } + } +} + #[derive(Clone)] pub struct AnalyticsPayloadBuilder { event: Option, diff --git a/crates/api-subscription/Cargo.toml b/crates/api-subscription/Cargo.toml index b068b9b14b..7b9219a03e 100644 --- a/crates/api-subscription/Cargo.toml +++ b/crates/api-subscription/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +hypr-analytics = { workspace = true } hypr-api-auth = { workspace = true } hypr-api-env = { workspace = true } diff --git a/crates/api-subscription/src/config.rs b/crates/api-subscription/src/config.rs index 00d1119b09..db5f2f8f2e 100644 --- a/crates/api-subscription/src/config.rs +++ b/crates/api-subscription/src/config.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use hypr_analytics::AnalyticsClient; + use crate::StripeEnv; use hypr_api_env::SupabaseEnv; @@ -5,6 +9,7 @@ use hypr_api_env::SupabaseEnv; pub struct SubscriptionConfig { pub supabase: SupabaseEnv, pub stripe: StripeEnv, + pub analytics: Option>, } impl SubscriptionConfig { @@ -12,6 +17,12 @@ impl SubscriptionConfig { Self { supabase: supabase.clone(), stripe: stripe.clone(), + analytics: None, } } + + pub fn with_analytics(mut self, analytics: Arc) -> Self { + self.analytics = Some(analytics); + self + } } diff --git a/crates/api-subscription/src/lib.rs b/crates/api-subscription/src/lib.rs index 271747ac9c..8f07f8377e 100644 --- a/crates/api-subscription/src/lib.rs +++ b/crates/api-subscription/src/lib.rs @@ -4,7 +4,9 @@ mod error; mod openapi; mod routes; mod state; +mod stripe; mod supabase; +mod trial; pub use config::SubscriptionConfig; pub use env::StripeEnv; diff --git a/crates/api-subscription/src/routes/billing.rs b/crates/api-subscription/src/routes/billing.rs index 7655be5ff5..0d94c3d4a2 100644 --- a/crates/api-subscription/src/routes/billing.rs +++ b/crates/api-subscription/src/routes/billing.rs @@ -4,61 +4,13 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use stripe::StripeRequest; -use stripe_billing::subscription::{ - CreateSubscription, CreateSubscriptionItems, CreateSubscriptionTrialSettings, - CreateSubscriptionTrialSettingsEndBehavior, - CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, -}; -use stripe_core::customer::CreateCustomer; -use utoipa::{IntoParams, ToSchema}; - +use hypr_analytics::{AnalyticsClient, ToAnalyticsPayload}; use hypr_api_auth::AuthContext; -use crate::error::{ErrorResponse, Result, SubscriptionError}; +use crate::error::ErrorResponse; use crate::state::AppState; - -#[derive(Debug, Deserialize, IntoParams)] -pub struct StartTrialQuery { - #[serde(default = "default_interval")] - #[param(example = "monthly")] - pub interval: Interval, -} - -fn default_interval() -> Interval { - Interval::Monthly -} - -#[derive(Debug, Deserialize, Clone, Copy, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum Interval { - Monthly, - Yearly, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum StartTrialReason { - Started, - NotEligible, - Error, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct StartTrialResponse { - #[schema(example = true)] - pub started: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, -} - -#[derive(Debug, Deserialize)] -struct Profile { - stripe_customer_id: Option, -} +use crate::stripe::{create_trial_subscription, get_or_create_customer}; +use crate::trial::{Interval, StartTrialQuery, StartTrialReason, StartTrialResponse, TrialOutcome}; #[utoipa::path( post, @@ -78,12 +30,11 @@ pub async fn start_trial( ) -> Response { let user_id = &auth.claims.sub; - let can_start_result: std::result::Result = state + let can_start: bool = match state .supabase .rpc("can_start_trial", &auth.token, None) - .await; - - let can_start = match can_start_result { + .await + { Ok(v) => v, Err(e) => { tracing::error!(error = %e, "can_start_trial RPC failed in start-trial"); @@ -98,169 +49,70 @@ pub async fn start_trial( } }; - if !can_start { - return Json(StartTrialResponse { - started: false, - reason: Some(StartTrialReason::NotEligible), - }) - .into_response(); - } - - let customer_id = match get_or_create_customer(&state, &auth.token, user_id).await { - Ok(Some(id)) => id, - Ok(None) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "stripe_customer_id_missing".to_string(), - }), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "get_or_create_customer failed"); - sentry::capture_message(&e.to_string(), sentry::Level::Error); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "failed_to_create_customer".to_string(), - }), - ) - .into_response(); - } - }; - - let price_id = match query.interval { - Interval::Monthly => &state.config.stripe.stripe_monthly_price_id, - Interval::Yearly => &state.config.stripe.stripe_yearly_price_id, - }; - - if let Err(e) = create_trial_subscription(&state.stripe, &customer_id, price_id, user_id).await - { - tracing::error!(error = %e, "failed to create Stripe subscription"); - sentry::capture_message(&e.to_string(), sentry::Level::Error); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: "failed_to_create_subscription".to_string(), - }), - ) - .into_response(); - } - - Json(StartTrialResponse { - started: true, - reason: Some(StartTrialReason::Started), - }) - .into_response() + let outcome = + if !can_start { + TrialOutcome::NotEligible + } else { + let customer_id = + match get_or_create_customer(&state.supabase, &state.stripe, &auth.token, user_id) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "stripe_customer_id_missing".to_string(), + }), + ) + .into_response(); + } + Err(e) => { + tracing::error!(error = %e, "get_or_create_customer failed"); + sentry::capture_message(&e.to_string(), sentry::Level::Error); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "failed_to_create_customer".to_string(), + }), + ) + .into_response(); + } + }; + + let price_id = match query.interval { + Interval::Monthly => &state.config.stripe.stripe_monthly_price_id, + Interval::Yearly => &state.config.stripe.stripe_yearly_price_id, + }; + + match create_trial_subscription(&state.stripe, &customer_id, price_id, user_id).await { + Ok(()) => TrialOutcome::Started(query.interval), + Err(e) => { + tracing::error!(error = %e, "failed to create Stripe subscription"); + sentry::capture_message(&e.to_string(), sentry::Level::Error); + TrialOutcome::StripeError + } + } + }; + + emit_and_respond(state.config.analytics.as_deref(), user_id, outcome).await } -async fn get_or_create_customer( - state: &AppState, - auth_token: &str, +async fn emit_and_respond( + analytics: Option<&AnalyticsClient>, user_id: &str, -) -> Result> { - let profiles: Vec = state - .supabase - .select( - "profiles", - auth_token, - "stripe_customer_id", - &[("id", &format!("eq.{}", user_id))], - ) - .await?; - - if let Some(profile) = profiles.first() - && let Some(customer_id) = &profile.stripe_customer_id - { - return Ok(Some(customer_id.clone())); - } - - let email = state.supabase.get_user_email(auth_token).await?; - - let metadata: HashMap = [("userId".to_string(), user_id.to_string())].into(); - - let mut create_customer = CreateCustomer::new().metadata(metadata); - - if let Some(ref email_str) = email { - create_customer = create_customer.email(email_str); - } - - let idempotency_key: stripe::IdempotencyKey = format!("create-customer-{}", user_id) - .try_into() - .map_err(|e: stripe::IdempotentKeyError| SubscriptionError::Internal(e.to_string()))?; - let customer = create_customer - .customize() - .request_strategy(stripe::RequestStrategy::Idempotent(idempotency_key)) - .send(&state.stripe) - .await - .map_err(|e: stripe::StripeError| SubscriptionError::Stripe(e.to_string()))?; - - #[derive(Serialize)] - struct UpdateData { - stripe_customer_id: String, + outcome: O, +) -> Response +where + O: IntoResponse + ToAnalyticsPayload, +{ + if let Some(analytics) = analytics { + let _ = analytics + .event(user_id, outcome.to_analytics_payload()) + .await; + if let Some(props) = outcome.to_analytics_properties() { + let _ = analytics.set_properties(user_id, props).await; + } } - - state - .supabase - .update( - "profiles", - auth_token, - &[ - ("id", &format!("eq.{}", user_id)), - ("stripe_customer_id", "is.null"), - ], - &UpdateData { - stripe_customer_id: customer.id.to_string(), - }, - ) - .await?; - - let updated_profiles: Vec = state - .supabase - .select( - "profiles", - auth_token, - "stripe_customer_id", - &[("id", &format!("eq.{}", user_id))], - ) - .await?; - - Ok(updated_profiles - .first() - .and_then(|p| p.stripe_customer_id.clone())) -} - -async fn create_trial_subscription( - stripe: &stripe::Client, - customer_id: &str, - price_id: &str, - user_id: &str, -) -> Result<()> { - let mut item = CreateSubscriptionItems::new(); - item.price = Some(price_id.to_string()); - - let create_sub = CreateSubscription::new() - .customer(customer_id) - .items(vec![item]) - .trial_period_days(14u32) - .trial_settings(CreateSubscriptionTrialSettings::new( - CreateSubscriptionTrialSettingsEndBehavior::new( - CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel, - ), - )); - - let date = Utc::now().format("%Y-%m-%d").to_string(); - let idempotency_key: stripe::IdempotencyKey = format!("trial-{}-{}", user_id, date) - .try_into() - .map_err(|e: stripe::IdempotentKeyError| SubscriptionError::Internal(e.to_string()))?; - - create_sub - .customize() - .request_strategy(stripe::RequestStrategy::Idempotent(idempotency_key)) - .send(stripe) - .await - .map_err(|e: stripe::StripeError| SubscriptionError::Stripe(e.to_string()))?; - - Ok(()) + outcome.into_response() } diff --git a/crates/api-subscription/src/routes/mod.rs b/crates/api-subscription/src/routes/mod.rs index 595f34958c..3c5b1d78ea 100644 --- a/crates/api-subscription/src/routes/mod.rs +++ b/crates/api-subscription/src/routes/mod.rs @@ -9,7 +9,7 @@ use axum::{ use crate::config::SubscriptionConfig; use crate::state::AppState; -pub use billing::{Interval, StartTrialReason, StartTrialResponse}; +pub use crate::trial::{Interval, StartTrialReason, StartTrialResponse}; pub use rpc::{CanStartTrialReason, CanStartTrialResponse}; pub fn router(config: SubscriptionConfig) -> Router { diff --git a/crates/api-subscription/src/stripe.rs b/crates/api-subscription/src/stripe.rs new file mode 100644 index 0000000000..5fd87e235c --- /dev/null +++ b/crates/api-subscription/src/stripe.rs @@ -0,0 +1,126 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use stripe::StripeRequest; +use stripe_billing::subscription::{ + CreateSubscription, CreateSubscriptionItems, CreateSubscriptionTrialSettings, + CreateSubscriptionTrialSettingsEndBehavior, + CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod, +}; +use stripe_core::customer::CreateCustomer; + +use crate::error::{Result, SubscriptionError}; +use crate::supabase::SupabaseClient; + +#[derive(Debug, Deserialize)] +struct Profile { + stripe_customer_id: Option, +} + +pub(crate) async fn get_or_create_customer( + supabase: &SupabaseClient, + stripe: &stripe::Client, + auth_token: &str, + user_id: &str, +) -> Result> { + let profiles: Vec = supabase + .select( + "profiles", + auth_token, + "stripe_customer_id", + &[("id", &format!("eq.{}", user_id))], + ) + .await?; + + if let Some(profile) = profiles.first() + && let Some(customer_id) = &profile.stripe_customer_id + { + return Ok(Some(customer_id.clone())); + } + + let email = supabase.get_user_email(auth_token).await?; + + let metadata: HashMap = [("userId".to_string(), user_id.to_string())].into(); + + let mut create_customer = CreateCustomer::new().metadata(metadata); + + if let Some(ref email_str) = email { + create_customer = create_customer.email(email_str); + } + + let idempotency_key: stripe::IdempotencyKey = format!("create-customer-{}", user_id) + .try_into() + .map_err(|e: stripe::IdempotentKeyError| SubscriptionError::Internal(e.to_string()))?; + let customer = create_customer + .customize() + .request_strategy(stripe::RequestStrategy::Idempotent(idempotency_key)) + .send(stripe) + .await + .map_err(|e: stripe::StripeError| SubscriptionError::Stripe(e.to_string()))?; + + #[derive(Serialize)] + struct UpdateData { + stripe_customer_id: String, + } + + supabase + .update( + "profiles", + auth_token, + &[ + ("id", &format!("eq.{}", user_id)), + ("stripe_customer_id", "is.null"), + ], + &UpdateData { + stripe_customer_id: customer.id.to_string(), + }, + ) + .await?; + + let updated_profiles: Vec = supabase + .select( + "profiles", + auth_token, + "stripe_customer_id", + &[("id", &format!("eq.{}", user_id))], + ) + .await?; + + Ok(updated_profiles + .first() + .and_then(|p| p.stripe_customer_id.clone())) +} + +pub(crate) async fn create_trial_subscription( + stripe: &stripe::Client, + customer_id: &str, + price_id: &str, + user_id: &str, +) -> Result<()> { + let mut item = CreateSubscriptionItems::new(); + item.price = Some(price_id.to_string()); + + let create_sub = CreateSubscription::new() + .customer(customer_id) + .items(vec![item]) + .trial_period_days(14u32) + .trial_settings(CreateSubscriptionTrialSettings::new( + CreateSubscriptionTrialSettingsEndBehavior::new( + CreateSubscriptionTrialSettingsEndBehaviorMissingPaymentMethod::Cancel, + ), + )); + + let date = Utc::now().format("%Y-%m-%d").to_string(); + let idempotency_key: stripe::IdempotencyKey = format!("trial-{}-{}", user_id, date) + .try_into() + .map_err(|e: stripe::IdempotentKeyError| SubscriptionError::Internal(e.to_string()))?; + + create_sub + .customize() + .request_strategy(stripe::RequestStrategy::Idempotent(idempotency_key)) + .send(stripe) + .await + .map_err(|e: stripe::StripeError| SubscriptionError::Stripe(e.to_string()))?; + + Ok(()) +} diff --git a/crates/api-subscription/src/trial.rs b/crates/api-subscription/src/trial.rs new file mode 100644 index 0000000000..1399cb318c --- /dev/null +++ b/crates/api-subscription/src/trial.rs @@ -0,0 +1,112 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::Utc; +use hypr_analytics::{AnalyticsPayload, PropertiesPayload, ToAnalyticsPayload}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::error::ErrorResponse; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct StartTrialQuery { + #[serde(default = "default_interval")] + #[param(example = "monthly")] + pub interval: Interval, +} + +fn default_interval() -> Interval { + Interval::Monthly +} + +#[derive(Debug, Deserialize, Clone, Copy, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Interval { + Monthly, + Yearly, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StartTrialReason { + Started, + NotEligible, + Error, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct StartTrialResponse { + #[schema(example = true)] + pub started: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +pub(crate) enum TrialOutcome { + NotEligible, + StripeError, + Started(Interval), +} + +impl ToAnalyticsPayload for TrialOutcome { + fn to_analytics_payload(&self) -> AnalyticsPayload { + match self { + Self::NotEligible => AnalyticsPayload::builder("trial_skipped") + .with("reason", "not_eligible") + .build(), + Self::StripeError => AnalyticsPayload::builder("trial_failed") + .with("reason", "stripe_error") + .build(), + Self::Started(interval) => { + let plan = match interval { + Interval::Monthly => "pro_monthly", + Interval::Yearly => "pro_yearly", + }; + AnalyticsPayload::builder("trial_started") + .with("plan", plan) + .build() + } + } + } + + fn to_analytics_properties(&self) -> Option { + match self { + Self::Started(_) => { + let trial_end_date = (Utc::now() + chrono::Duration::days(14)).to_rfc3339(); + Some( + PropertiesPayload::builder() + .set("plan", "pro") + .set("trial_end_date", trial_end_date) + .build(), + ) + } + _ => None, + } + } +} + +impl IntoResponse for TrialOutcome { + fn into_response(self) -> Response { + match self { + Self::NotEligible => Json(StartTrialResponse { + started: false, + reason: Some(StartTrialReason::NotEligible), + }) + .into_response(), + Self::StripeError => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "failed_to_create_subscription".to_string(), + }), + ) + .into_response(), + Self::Started(_) => Json(StartTrialResponse { + started: true, + reason: Some(StartTrialReason::Started), + }) + .into_response(), + } + } +} diff --git a/crates/owhisper-interface/src/stream.rs b/crates/owhisper-interface/src/stream.rs index e628ccc614..eaddd44b99 100644 --- a/crates/owhisper-interface/src/stream.rs +++ b/crates/owhisper-interface/src/stream.rs @@ -182,7 +182,12 @@ impl StreamResponse { pub fn set_extra(&mut self, extra: &Extra) { if let StreamResponse::TranscriptResponse { metadata, .. } = self { - metadata.extra = Some(extra.clone().into()); + let incoming: std::collections::HashMap = + extra.clone().into(); + match &mut metadata.extra { + Some(existing) => existing.extend(incoming), + slot => *slot = Some(incoming), + } } }