1use std::fmt;
21
22pub const API_URL : &str = "https://api.anthropic.com/v1/messages";
26
27pub const ANTHROPIC_BETA : &str = "oauth-2025-04-20";
36
37pub const ANTHROPIC_VERSION : &str = "2023-06-01";
39
40#[ derive( Debug ) ]
44pub struct RateLimitData
45{
46 pub utilization_5h : f64,
48 pub reset_5h : u64,
50 pub utilization_7d : f64,
52 pub reset_7d : u64,
54 pub status : String,
56}
57
58#[ derive( Debug ) ]
62pub enum QuotaError
63{
64 HttpTransport( String ),
66 MissingHeader( String ),
68 MalformedHeader( String ),
70 ResponseParse( String ),
73}
74
75impl fmt::Display for QuotaError
76{
77 #[ inline ]
78 fn fmt( &self, f : &mut fmt::Formatter< '_ > ) -> fmt::Result
79 {
80 match self
81 {
82 Self::HttpTransport( msg ) =>
83 write!( f, "HTTP transport error: {msg}" ),
84 Self::MissingHeader( name ) =>
85 write!( f, "rate-limit header missing: {name}" ),
86 Self::MalformedHeader( ctx ) =>
87 write!( f, "rate-limit header malformed: {ctx}" ),
88 Self::ResponseParse( field ) =>
89 write!( f, "OAuth usage response parse error: missing or malformed field '{field}'" ),
90 }
91 }
92}
93
94impl std::error::Error for QuotaError {}
95
96#[ inline ]
112#[ allow( clippy::similar_names ) ]
113pub fn parse_headers< F >( get : F ) -> Result< RateLimitData, QuotaError >
114where
115 F : Fn( &str ) -> Option< String >,
116{
117 let require = |name : &str| -> Result< String, QuotaError >
118 {
119 get( name ).ok_or_else( || QuotaError::MissingHeader( name.to_string() ) )
120 };
121
122 let utilization_5h = require( "anthropic-ratelimit-unified-5h-utilization" )?
123 .parse::< f64 >().map_err( |e|
124 QuotaError::MalformedHeader( format!( "5h-utilization: {e}" ) )
125 )?;
126 let reset_5h = require( "anthropic-ratelimit-unified-5h-reset" )?
127 .parse::< u64 >().map_err( |e|
128 QuotaError::MalformedHeader( format!( "5h-reset: {e}" ) )
129 )?;
130 let utilization_7d = require( "anthropic-ratelimit-unified-7d-utilization" )?
131 .parse::< f64 >().map_err( |e|
132 QuotaError::MalformedHeader( format!( "7d-utilization: {e}" ) )
133 )?;
134 let reset_7d = require( "anthropic-ratelimit-unified-7d-reset" )?
135 .parse::< u64 >().map_err( |e|
136 QuotaError::MalformedHeader( format!( "7d-reset: {e}" ) )
137 )?;
138 let status = require( "anthropic-ratelimit-unified-status" )?;
139
140 Ok( RateLimitData
141 {
142 utilization_5h,
143 reset_5h,
144 utilization_7d,
145 reset_7d,
146 status,
147 } )
148}
149
150#[ cfg( feature = "enabled" ) ]
161#[ inline ]
162fn http_agent() -> ureq::Agent
163{
164 let config = ureq::Agent::config_builder()
165 .timeout_recv_body( Some( core::time::Duration::from_secs( 10 ) ) )
166 .timeout_connect( Some( core::time::Duration::from_secs( 5 ) ) )
167 .http_status_as_error( false )
168 .build();
169 ureq::Agent::new_with_config( config )
170}
171
172#[ cfg( feature = "enabled" ) ]
194#[ inline ]
195pub fn fetch_rate_limits( token : &str ) -> Result< RateLimitData, QuotaError >
196{
197 let body = r#"{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"quota"}]}"#;
198
199 let resp = http_agent()
200 .post( API_URL )
201 .header( "Authorization", &format!( "Bearer {token}" ) )
202 .header( "anthropic-beta", ANTHROPIC_BETA )
203 .header( "anthropic-version", ANTHROPIC_VERSION )
204 .header( "Content-Type", "application/json" )
205 .send( body )
206 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
207
208 parse_headers( |name|
212 resp.headers().get( name )
213 .and_then( |v| v.to_str().ok() )
214 .map( str::to_string )
215 )
216}
217
218pub const OAUTH_USAGE_URL : &str = "https://api.anthropic.com/api/oauth/usage";
222
223#[ derive( Debug ) ]
228pub struct PeriodUsage
229{
230 pub utilization : f64,
232 pub resets_at : Option< String >,
234}
235
236#[ derive( Debug ) ]
240pub struct OauthUsageData
241{
242 pub five_hour : Option< PeriodUsage >,
244 pub seven_day : Option< PeriodUsage >,
246 pub seven_day_sonnet : Option< PeriodUsage >,
248}
249
250#[ inline ]
263#[ must_use ]
264pub fn iso_to_unix_secs( s : &str ) -> Option< u64 >
265{
266 if s.len() < 19 { return None; }
268
269 let t_pos = s.find( 'T' )?;
271 let date_part = &s[ ..t_pos ];
272 let time_part = &s[ t_pos + 1 .. ];
273
274 if date_part.len() < 10 { return None; }
276 let year = date_part[ 0..4 ].parse::< u64 >().ok()?;
277 let month = date_part[ 5..7 ].parse::< u64 >().ok()?;
278 let day = date_part[ 8..10 ].parse::< u64 >().ok()?;
279 if !( 1..=12 ).contains( &month ) || !( 1..=31 ).contains( &day ) { return None; }
280
281 if time_part.len() < 8 { return None; }
283 let hour = time_part[ 0..2 ].parse::< u64 >().ok()?;
284 let min = time_part[ 3..5 ].parse::< u64 >().ok()?;
285 let sec = time_part[ 6..8 ].parse::< u64 >().ok()?;
286 if hour > 23 || min > 59 || sec > 59 { return None; }
287
288 let is_leap = |y : u64| ( y % 4 == 0 && y % 100 != 0 ) || y % 400 == 0;
290 let mut days : u64 = 0;
291 for y in 1970..year
292 {
293 days += if is_leap( y ) { 366 } else { 365 };
294 }
295
296 let days_in_month = [ 31u64, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
298 for m in 1..month
299 {
300 let extra = u64::from( m == 2 && is_leap( year ) );
301 days += days_in_month[ usize::try_from( m - 1 ).unwrap_or( 0 ) ] + extra;
302 }
303
304 days += day - 1;
305
306 Some( days * 86_400 + hour * 3_600 + min * 60 + sec )
307}
308
309#[ inline ]
322pub fn parse_oauth_usage( body : &str ) -> Result< OauthUsageData, QuotaError >
323{
324 if !body.contains( "\"five_hour\"" )
327 && !body.contains( "\"seven_day\"" )
328 && !body.contains( "\"seven_day_sonnet\"" )
329 {
330 return Err( QuotaError::ResponseParse( "five_hour/seven_day/seven_day_sonnet".to_string() ) );
331 }
332
333 let five_hour = parse_period( body, "five_hour" )?;
334 let seven_day = parse_period( body, "seven_day" )?;
335 let seven_day_sonnet = parse_period( body, "seven_day_sonnet" )?;
336
337 let seven_day_sonnet = seven_day_sonnet
341 .or_else( || scan_limits_for_kind( body, &[ "weekly_sonnet", "sonnet" ] ) );
342
343 Ok( OauthUsageData { five_hour, seven_day, seven_day_sonnet } )
344}
345
346fn extract_object_block( s : &str ) -> Option< &str >
351{
352 if !s.starts_with( '{' ) { return None; }
353 let mut depth = 0_i32;
354 for ( i, c ) in s.char_indices()
355 {
356 match c
357 {
358 '{' => depth += 1,
359 '}' =>
360 {
361 depth -= 1;
362 if depth == 0 { return Some( &s[ ..=i ] ); }
363 }
364 _ => {}
365 }
366 }
367 None
368}
369
370fn parse_period( body : &str, key : &str ) -> Result< Option< PeriodUsage >, QuotaError >
379{
380 let needle = format!( "\"{key}\":" );
381 let after_key = body
382 .find( needle.as_str() )
383 .map( |pos| &body[ pos + needle.len() .. ] )
384 .ok_or_else( || QuotaError::ResponseParse( key.to_string() ) )?;
385
386 let value_start = after_key.trim_start();
387
388 if value_start.starts_with( "null" )
390 {
391 return Ok( None );
392 }
393
394 if !value_start.starts_with( '{' )
396 {
397 return Err( QuotaError::ResponseParse( format!( "{key}: expected object or null" ) ) );
398 }
399
400 let block = extract_object_block( value_start )
401 .ok_or_else( || QuotaError::ResponseParse( format!( "{key}: unclosed object" ) ) )?;
402
403 let utilization = parse_f64_in_block( block, "utilization" )
405 .ok_or_else( || QuotaError::ResponseParse( format!( "{key}.utilization" ) ) )?;
406
407 let resets_at = parse_optional_string_in_block( block, "resets_at" );
409
410 Ok( Some( PeriodUsage { utilization, resets_at } ) )
411}
412
413fn parse_f64_in_block( block : &str, key : &str ) -> Option< f64 >
417{
418 let needle = format!( "\"{key}\":" );
419 let after_key = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
420 let value = after_key.trim_start();
421
422 if value.starts_with( '"' ) { return None; }
424
425 let end = value
427 .find( |c : char| !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+' )
428 .unwrap_or( value.len() );
429 value[ ..end ].parse::< f64 >().ok()
430}
431
432fn parse_optional_string_in_block( block : &str, key : &str ) -> Option< String >
436{
437 let needle = format!( "\"{key}\":" );
438 let after_key = block.find( needle.as_str() ).map( |p| &block[ p + needle.len() .. ] )?;
439 let value = after_key.trim_start();
440
441 if value.starts_with( "null" ) { return None; }
443
444 if let Some( inner ) = value.strip_prefix( '"' )
446 {
447 let end = inner.find( '"' )?;
448 return Some( inner[ ..end ].to_string() );
449 }
450
451 None
452}
453
454fn scan_limits_for_kind( body : &str, kind_needles : &[ &str ] ) -> Option< PeriodUsage >
463{
464 let needle = "\"limits\":";
466 let pos = body.find( needle )?;
467 let after = body[ pos + needle.len() .. ].trim_start();
468 if !after.starts_with( '[' ) { return None; }
469
470 let mut rest = after[ 1.. ].trim_start(); loop
473 {
474 rest = rest.trim_start();
475 if rest.starts_with( ']' ) || rest.is_empty() { break; }
476 if rest.starts_with( ',' ) { rest = &rest[ 1.. ]; continue; }
477
478 let block = extract_object_block( rest )?;
479 rest = &rest[ block.len().. ];
480
481 let kind_val = parse_optional_string_in_block( block, "kind" ).unwrap_or_default();
483 let scope_val = parse_optional_string_in_block( block, "scope" ).unwrap_or_default();
484 let matched = kind_needles
485 .iter()
486 .any( |n| kind_val.contains( *n ) || scope_val.contains( *n ) );
487 if !matched { continue; }
488
489 let utilization = parse_f64_in_block( block, "percent" )?;
491 let resets_at = parse_optional_string_in_block( block, "resets_at" );
492 return Some( PeriodUsage { utilization, resets_at } );
493 }
494 None
495}
496
497#[ cfg( feature = "enabled" ) ]
508#[ inline ]
509pub fn fetch_oauth_usage( token : &str ) -> Result< OauthUsageData, QuotaError >
510{
511 let mut resp = http_agent()
512 .get( OAUTH_USAGE_URL )
513 .header( "Authorization", &format!( "Bearer {token}" ) )
514 .call()
515 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
516
517 let status = resp.status().as_u16();
518 if status >= 400
519 {
520 return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
521 }
522
523 let body = resp
524 .body_mut()
525 .read_to_string()
526 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
527
528 parse_oauth_usage( &body )
529}
530
531pub const OAUTH_ACCOUNT_URL : &str = "https://api.anthropic.com/api/oauth/account";
535
536#[ derive( Debug ) ]
550pub struct OauthAccountData
551{
552 pub tagged_id : String,
554 pub uuid : String,
556 pub email_address : String,
558 pub full_name : String,
560 pub display_name : String,
562 pub billing_type : String,
564 pub has_max : bool,
566 pub capabilities : Vec< String >,
568 pub rate_limit_tier : String,
570 pub org_created_at : String,
572 pub memberships : Vec< MembershipRaw >,
574}
575
576#[ derive( Debug ) ]
581pub struct MembershipRaw
582{
583 pub index : usize,
585 pub billing_type : String,
587 pub has_max : bool,
589 pub capabilities : Vec< String >,
591 pub org_created_at : String,
593 pub rate_limit_tier : String,
595}
596
597#[ inline ]
608#[ must_use ]
609pub fn select_membership_index( memberships : &[ MembershipRaw ] ) -> usize
610{
611 if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" && m.has_max )
613 {
614 return m.index;
615 }
616 if let Some( m ) = memberships.iter().find( |m| m.billing_type == "stripe_subscription" )
618 {
619 return m.index;
620 }
621 0
623}
624
625fn parse_string_array( block : &str, key : &str ) -> Vec< String >
630{
631 let needle = format!( "\"{key}\":" );
632 let Some( pos ) = block.find( needle.as_str() ) else { return vec![]; };
633 let rest = block[ pos + needle.len() .. ].trim_start();
634 let Some( arr_start ) = rest.find( '[' ) else { return vec![]; };
635 let inner = &rest[ arr_start + 1 .. ];
636 let Some( arr_end ) = inner.find( ']' ) else { return vec![]; };
637 let array_content = &inner[ ..arr_end ];
638 let mut caps = Vec::new();
639 let mut scan = array_content;
640 while let Some( start ) = scan.find( '"' )
641 {
642 scan = &scan[ start + 1 .. ];
643 let Some( end ) = scan.find( '"' ) else { break; };
644 let token = &scan[ ..end ];
645 if !token.is_empty() { caps.push( token.to_string() ); }
646 scan = &scan[ end + 1 .. ];
647 }
648 caps
649}
650
651fn parse_membership_list( body : &str ) -> Result< Vec< MembershipRaw >, QuotaError >
659{
660 let mem_label = "\"memberships\":";
661 let mem_pos = body
662 .find( mem_label )
663 .ok_or_else( || QuotaError::ResponseParse( "memberships".to_string() ) )?;
664 let after_label = &body[ mem_pos + mem_label.len() .. ];
665 let arr_offset = after_label
666 .find( '[' )
667 .ok_or_else( || QuotaError::ResponseParse( "memberships: no array".to_string() ) )?;
668 let array_body = &after_label[ arr_offset + 1 .. ];
669
670 let mut memberships = Vec::new();
671 let mut pos = 0_usize;
672 let mut idx = 0_usize;
673
674 loop
675 {
676 let rest = &array_body[ pos .. ];
677 let close_pos = rest.find( ']' ).unwrap_or( rest.len() );
679 let obj_pos = match rest.find( '{' )
680 {
681 Some( p ) if p < close_pos => p,
682 _ => break,
683 };
684 let obj_slice = &rest[ obj_pos .. ];
685 let Some( membership_block ) = extract_object_block( obj_slice ) else { break };
686
687 if let Some( org_label_pos ) = membership_block.find( "\"organization\":" )
689 {
690 let org_label = "\"organization\":";
691 let after_org = membership_block[ org_label_pos + org_label.len() .. ].trim_start();
692 if let Some( org_block ) = extract_object_block( after_org )
693 {
694 let billing_type = parse_optional_string_in_block( org_block, "billing_type" )
695 .unwrap_or_default();
696 let has_max = org_block.contains( "\"claude_max\"" );
697 let org_created_at = parse_optional_string_in_block( org_block, "created_at" )
698 .unwrap_or_default();
699 let capabilities = parse_string_array( org_block, "capabilities" );
700 let rate_limit_tier = parse_optional_string_in_block( org_block, "rate_limit_tier" )
701 .unwrap_or_default();
702 memberships.push( MembershipRaw { index: idx, billing_type, has_max, capabilities, org_created_at, rate_limit_tier } );
703 }
704 }
705
706 pos += obj_pos + membership_block.len();
707 idx += 1;
708 }
709
710 if memberships.is_empty()
711 {
712 return Err( QuotaError::ResponseParse( "memberships: empty or missing organization".to_string() ) );
713 }
714 Ok( memberships )
715}
716
717#[ inline ]
741pub fn parse_oauth_account( body : &str ) -> Result< OauthAccountData, QuotaError >
742{
743 let tagged_id = parse_optional_string_in_block( body, "tagged_id" )
745 .unwrap_or_default();
746 let uuid = parse_optional_string_in_block( body, "uuid" )
747 .unwrap_or_default();
748 let email_address = parse_optional_string_in_block( body, "email_address" )
749 .unwrap_or_default();
750 let full_name = parse_optional_string_in_block( body, "full_name" )
751 .unwrap_or_default();
752 let display_name = parse_optional_string_in_block( body, "display_name" )
753 .unwrap_or_default();
754
755 let memberships = parse_membership_list( body )?;
756 let sel = select_membership_index( &memberships );
757 let m = &memberships[ sel ];
758 if m.billing_type.is_empty()
759 {
760 return Err( QuotaError::ResponseParse( "organization.billing_type".to_string() ) );
761 }
762 Ok( OauthAccountData
763 {
764 tagged_id,
765 uuid,
766 email_address,
767 full_name,
768 display_name,
769 billing_type : m.billing_type.clone(),
770 has_max : m.has_max,
771 capabilities : m.capabilities.clone(),
772 rate_limit_tier : m.rate_limit_tier.clone(),
773 org_created_at : m.org_created_at.clone(),
774 memberships,
775 } )
776}
777
778#[ cfg( feature = "enabled" ) ]
787#[ inline ]
788pub fn fetch_oauth_account( token : &str ) -> Result< OauthAccountData, QuotaError >
789{
790 let mut resp = http_agent()
791 .get( OAUTH_ACCOUNT_URL )
792 .header( "Authorization", &format!( "Bearer {token}" ) )
793 .header( "anthropic-version", ANTHROPIC_VERSION )
794 .call()
795 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
796
797 let status = resp.status().as_u16();
798 if status >= 400
799 {
800 return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
801 }
802
803 let body = resp
804 .body_mut()
805 .read_to_string()
806 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
807
808 parse_oauth_account( &body )
809}
810
811pub const CLAUDE_CLI_ROLES_URL : &str = "https://api.anthropic.com/api/oauth/claude_cli/roles";
815
816#[ derive( Debug ) ]
821pub struct ClaudeCliRolesData
822{
823 pub organization_uuid : String,
825 pub organization_name : String,
827 pub organization_role : String,
829 pub workspace_uuid : String,
831 pub workspace_name : String,
833}
834
835#[ inline ]
848pub fn parse_claude_cli_roles( body : &str ) -> Result< ClaudeCliRolesData, QuotaError >
849{
850 let organization_uuid = parse_optional_string_in_block( body, "organization_uuid" )
851 .ok_or_else( || QuotaError::ResponseParse( "organization_uuid".to_string() ) )?;
852 let organization_name = parse_optional_string_in_block( body, "organization_name" )
853 .ok_or_else( || QuotaError::ResponseParse( "organization_name".to_string() ) )?;
854 let organization_role = parse_optional_string_in_block( body, "organization_role" )
855 .unwrap_or_default();
856 let workspace_uuid = parse_optional_string_in_block( body, "workspace_uuid" )
857 .unwrap_or_default();
858 let workspace_name = parse_optional_string_in_block( body, "workspace_name" )
859 .unwrap_or_default();
860
861 Ok( ClaudeCliRolesData
862 {
863 organization_uuid,
864 organization_name,
865 organization_role,
866 workspace_uuid,
867 workspace_name,
868 } )
869}
870
871#[ cfg( feature = "enabled" ) ]
880#[ inline ]
881pub fn fetch_claude_cli_roles( token : &str ) -> Result< ClaudeCliRolesData, QuotaError >
882{
883 let mut resp = http_agent()
884 .get( CLAUDE_CLI_ROLES_URL )
885 .header( "Authorization", &format!( "Bearer {token}" ) )
886 .header( "anthropic-version", ANTHROPIC_VERSION )
887 .call()
888 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
889
890 let status = resp.status().as_u16();
891 if status >= 400
892 {
893 return Err( QuotaError::HttpTransport( format!( "HTTP {status}" ) ) );
894 }
895
896 let body = resp
897 .body_mut()
898 .read_to_string()
899 .map_err( |e| QuotaError::HttpTransport( e.to_string() ) )?;
900
901 parse_claude_cli_roles( &body )
902}
903
904#[ cfg( test ) ]
907mod tests
908{
909 use super::*;
910
911 #[ test ]
914 #[ doc = "`bug_reproducer(237)`" ]
915 fn mre_bug237_multi_membership_selects_stripe_max_over_none()
938 {
939 let body = r#"{
940 "tagged_id": "user_01ABC",
941 "uuid": "aaaa-bbbb",
942 "email_address": "alice@acme.com",
943 "full_name": "Alice",
944 "display_name": "Alice",
945 "memberships": [
946 { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
947 { "role": "admin", "organization": { "billing_type": "stripe_subscription", "capabilities": ["claude_max","chat"], "rate_limit_tier": "default_claude_max_20x", "created_at": "2024-02-01T00:00:00Z" } }
948 ]
949 }"#;
950 let result = parse_oauth_account( body ).expect( "should parse" );
951 assert_eq!( result.billing_type, "stripe_subscription", "must select membership[1] (stripe+max), not membership[0] (none)" );
952 assert!( result.has_max, "membership[1] has claude_max capability" );
953 assert_eq!( result.org_created_at, "2024-02-01T00:00:00Z" );
954 assert_eq!( result.tagged_id, "user_01ABC" );
956 assert_eq!( result.uuid, "aaaa-bbbb" );
957 assert_eq!( result.email_address, "alice@acme.com" );
958 assert_eq!( result.full_name, "Alice" );
959 assert_eq!( result.display_name, "Alice" );
960 assert_eq!( result.capabilities, vec![ "claude_max", "chat" ] );
961 assert_eq!( result.rate_limit_tier, "default_claude_max_20x" );
962 assert_eq!( result.memberships.len(), 2, "all memberships preserved" );
963 }
964
965 #[ test ]
966 #[ doc = "`bug_reproducer(237)`" ]
967 fn mre_bug237_multi_membership_selects_stripe_over_none_no_max()
969 {
970 let body = r#"{
971 "tagged_id": "user_02XYZ",
972 "uuid": "cccc-dddd",
973 "email_address": "bob@example.com",
974 "memberships": [
975 { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } },
976 { "role": "admin", "organization": { "billing_type": "stripe_subscription", "capabilities": ["chat"], "created_at": "2024-03-01T00:00:00Z" } }
977 ]
978 }"#;
979 let result = parse_oauth_account( body ).expect( "should parse" );
980 assert_eq!( result.billing_type, "stripe_subscription" );
981 assert!( !result.has_max, "no claude_max in membership[1]" );
982 assert_eq!( result.org_created_at, "2024-03-01T00:00:00Z" );
983 assert_eq!( result.tagged_id, "user_02XYZ" );
984 assert_eq!( result.email_address, "bob@example.com" );
985 assert!( result.rate_limit_tier.is_empty(), "no rate_limit_tier in fixture" );
986 }
987
988 #[ test ]
989 #[ doc = "`bug_reproducer(237)`" ]
990 fn mre_bug237_single_membership_fallback_unchanged()
992 {
993 let body = r#"{
994 "tagged_id": "user_03QRS",
995 "uuid": "eeee-ffff",
996 "email_address": "carol@example.com",
997 "full_name": "Carol",
998 "display_name": "Carol",
999 "memberships": [
1000 { "role": "member", "organization": { "billing_type": "none", "capabilities": ["chat"], "created_at": "2024-01-01T00:00:00Z" } }
1001 ]
1002 }"#;
1003 let result = parse_oauth_account( body ).expect( "should parse" );
1004 assert_eq!( result.billing_type, "none", "single membership always selected via Priority 3" );
1005 assert!( !result.has_max );
1006 assert_eq!( result.memberships.len(), 1, "single membership preserved" );
1007 assert_eq!( result.tagged_id, "user_03QRS" );
1008 assert_eq!( result.full_name, "Carol" );
1009 }
1010}