| ... | @@ -189,7 +189,21 @@ export function collectLimitAccounts( | ... | @@ -189,7 +189,21 @@ export function collectLimitAccounts( |
| 189 | ): readonly LimitAccount[] { | 189 | ): readonly LimitAccount[] { |
| 190 | const accounts = new Map<string, LimitAccount>(); | 190 | const accounts = new Map<string, LimitAccount>(); |
| 191 | const creditSources = new Map<string, LimitAccount>(); | 191 | const creditSources = new Map<string, LimitAccount>(); |
| | 192 | const hubRedeems = new Map<string, LimitAccount>(); |
| 192 | const merge = (key: string, next: LimitAccount) => { | 193 | const merge = (key: string, next: LimitAccount) => { |
| | 194 | // Redeeming through a hub also clears the routing cooldown that hub holds |
| | 195 | // for the account. Redeeming natively against the same subscription resets |
| | 196 | // it upstream but leaves the hub refusing to route to the account until |
| | 197 | // its own cooldown expires, so a hub target wins the redemption outright |
| | 198 | // while the displayed balance still follows the freshest read. |
| | 199 | const previousHub = hubRedeems.get(key); |
| | 200 | if ( |
| | 201 | next.redeem && |
| | 202 | "sourceId" in next.redeem.input && |
| | 203 | (!previousHub || Date.parse(next.limits.checkedAt) > Date.parse(previousHub.limits.checkedAt)) |
| | 204 | ) { |
| | 205 | hubRedeems.set(key, next); |
| | 206 | } |
| 193 | const previousCredit = creditSources.get(key); | 207 | const previousCredit = creditSources.get(key); |
| 194 | if ( | 208 | if ( |
| 195 | next.limits.resetCredits && | 209 | next.limits.resetCredits && |
| ... | @@ -224,9 +238,9 @@ export function collectLimitAccounts( | ... | @@ -224,9 +238,9 @@ export function collectLimitAccounts( |
| 224 | environments, | 238 | environments, |
| 225 | // A hub only names the account when no environment has it natively. | 239 | // A hub only names the account when no environment has it natively. |
| 226 | sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), | 240 | sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), |
| 227 | redeem: creditSource | 241 | redeem: |
| 228 | ? creditSource.redeem | 242 | hubRedeems.get(key)?.redeem ?? |
| 229 | : (winner.redeem ?? previous.redeem ?? next.redeem), | 243 | (creditSource ? creditSource.redeem : (winner.redeem ?? previous.redeem ?? next.redeem)), |
| 230 | limits: { | 244 | limits: { |
| 231 | ...winner.limits, | 245 | ...winner.limits, |
| 232 | ...(creditSource?.limits.resetCredits | 246 | ...(creditSource?.limits.resetCredits |
| ... | @@ -645,14 +659,20 @@ export function collectProviderUsageLimits( | ... | @@ -645,14 +659,20 @@ export function collectProviderUsageLimits( |
| 645 | (a, b) => | 659 | (a, b) => |
| 646 | Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt), | 660 | Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt), |
| 647 | )[0]; | 661 | )[0]; |
| 648 | const useHubCredits = | 662 | // Two independent decisions. Which balance to *display* follows whichever |
| | 663 | // snapshot is fresher. Which path to *redeem through* always prefers the |
| | 664 | // hub, because only the hub path clears the routing cooldown it holds for |
| | 665 | // that account; redeeming natively against the same account resets the |
| | 666 | // subscription upstream but leaves the hub refusing to route to it until |
| | 667 | // its own cooldown expires. A hub credit id that a fresher native redeem |
| | 668 | // already spent comes back as `alreadyRedeemed`, which still clears the |
| | 669 | // cooldown, so preferring it is safe even when the hub snapshot is stale. |
| | 670 | const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId; |
| | 671 | const showHubCredits = |
| 649 | hubCredits && | 672 | hubCredits && |
| 650 | (!provider.usageLimits.resetCredits || | 673 | (!provider.usageLimits.resetCredits || |
| 651 | Date.parse(hubCredits.account.usageLimits.checkedAt) > | 674 | Date.parse(hubCredits.account.usageLimits.checkedAt) > |
| 652 | Date.parse(provider.usageLimits.checkedAt)); | 675 | Date.parse(provider.usageLimits.checkedAt)); |
| 653 | const hubCreditId = useHubCredits | | |
| 654 | ? hubCredits.account.usageLimits.resetCredits?.nextCreditId | | |
| 655 | : undefined; | | |
| 656 | accounts.push({ | 676 | accounts.push({ |
| 657 | id: provider.instanceId, | 677 | id: provider.instanceId, |
| 658 | driver: provider.driver, | 678 | driver: provider.driver, |
| ... | @@ -670,7 +690,7 @@ export function collectProviderUsageLimits( | ... | @@ -670,7 +690,7 @@ export function collectProviderUsageLimits( |
| 670 | ...(provider.displayName ? { displayName: provider.displayName } : {}), | 690 | ...(provider.displayName ? { displayName: provider.displayName } : {}), |
| 671 | ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), | 691 | ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), |
| 672 | ...(provider.auth.email ? { email: provider.auth.email } : {}), | 692 | ...(provider.auth.email ? { email: provider.auth.email } : {}), |
| 673 | limits: useHubCredits | 693 | limits: showHubCredits |
| 674 | ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits } | 694 | ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits } |
| 675 | : provider.usageLimits, | 695 | : provider.usageLimits, |
| 676 | }); | 696 | }); |