fcloud-sdk 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/CHANGELOG.md +44 -0
  2. {fcloud_sdk-0.3.0/src/fcloud_sdk.egg-info → fcloud_sdk-0.3.1}/PKG-INFO +1 -1
  3. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/pyproject.toml +1 -1
  4. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/billing_types.py +57 -0
  5. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits.py +78 -10
  6. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits_buy.py +22 -4
  7. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits_render.py +5 -1
  8. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli_args.py +2 -1
  9. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_billing.py +20 -13
  10. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/config.py +15 -9
  11. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1/src/fcloud_sdk.egg-info}/PKG-INFO +1 -1
  12. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/SOURCES.txt +1 -0
  13. fcloud_sdk-0.3.1/tests/test_billing_types.py +93 -0
  14. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_http_contract.py +39 -0
  15. fcloud_sdk-0.3.1/tests/test_credits_cli.py +450 -0
  16. fcloud_sdk-0.3.0/tests/test_credits_cli.py +0 -237
  17. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/CONTRIBUTING.md +0 -0
  18. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/LICENSE +0 -0
  19. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/MANIFEST.in +0 -0
  20. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/NOTICE +0 -0
  21. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/README.md +0 -0
  22. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/SKILL.md +0 -0
  23. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/setup.cfg +0 -0
  24. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/SKILL.md +0 -0
  25. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/__init__.py +0 -0
  26. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/__main__.py +0 -0
  27. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_direct_bridge.py +0 -0
  28. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_legacy_env.py +0 -0
  29. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_modal_runner.py +0 -0
  30. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/SETUP.md +0 -0
  31. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/program.template.md +0 -0
  32. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/__init__.py +0 -0
  33. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/attach.py +0 -0
  34. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/autoresearch.py +0 -0
  35. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/common.py +0 -0
  36. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/config_cmd.py +0 -0
  37. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/console.py +0 -0
  38. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/context.py +0 -0
  39. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/exec_cmd.py +0 -0
  40. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/files.py +0 -0
  41. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/hardware.py +0 -0
  42. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/help.py +0 -0
  43. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/interactive.py +0 -0
  44. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/job.py +0 -0
  45. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/main.py +0 -0
  46. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/migration.py +0 -0
  47. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/mount.py +0 -0
  48. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/output.py +0 -0
  49. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/processes.py +0 -0
  50. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/registry.py +0 -0
  51. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/run.py +0 -0
  52. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sessions.py +0 -0
  53. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/setup.py +0 -0
  54. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/ssh.py +0 -0
  55. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep.py +0 -0
  56. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_harvest.py +0 -0
  57. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_watch.py +0 -0
  58. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/usage.py +0 -0
  59. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/volume.py +0 -0
  60. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/wait.py +0 -0
  61. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client.py +0 -0
  62. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_projects.py +0 -0
  63. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_sessions.py +0 -0
  64. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_volumes.py +0 -0
  65. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/direct.py +0 -0
  66. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/errors.py +0 -0
  67. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/fileset.py +0 -0
  68. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/image.py +0 -0
  69. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/job.py +0 -0
  70. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/login.py +0 -0
  71. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_compat.py +0 -0
  72. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_objects.py +0 -0
  73. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_run.py +0 -0
  74. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/providers/__init__.py +0 -0
  75. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/providers/requests_http.py +0 -0
  76. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/py.typed +0 -0
  77. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/session.py +0 -0
  78. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/setup_cmd.py +0 -0
  79. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/shell.py +0 -0
  80. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/sweeps.py +0 -0
  81. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/tunnel.py +0 -0
  82. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/types.py +0 -0
  83. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/v2_connect.py +0 -0
  84. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/version.py +0 -0
  85. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/volume_wait.py +0 -0
  86. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/volumes.py +0 -0
  87. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
  88. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
  89. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/requires.txt +0 -0
  90. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
  91. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/foom/__init__.py +0 -0
  92. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/conftest.py +0 -0
  93. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_autoresearch.py +0 -0
  94. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_checkpoint_option.py +0 -0
  95. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_args.py +0 -0
  96. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_dispatch.py +0 -0
  97. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_guards.py +0 -0
  98. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_help.py +0 -0
  99. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client.py +0 -0
  100. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_contracts_c.py +0 -0
  101. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_host_frames_contract.py +0 -0
  102. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_telemetry.py +0 -0
  103. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_config.py +0 -0
  104. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_config_cmd.py +0 -0
  105. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_credit_preflight.py +0 -0
  106. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_credits_errors.py +0 -0
  107. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_default_image.py +0 -0
  108. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_direct_bridge.py +0 -0
  109. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_direct_fake_host.py +0 -0
  110. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_dotenv_precedence.py +0 -0
  111. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_download_promotion_retry.py +0 -0
  112. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_download_volume_hint.py +0 -0
  113. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_emit_pid.py +0 -0
  114. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_exec_attach_retry.py +0 -0
  115. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_fileset.py +0 -0
  116. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_first_run_login.py +0 -0
  117. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_job.py +0 -0
  118. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_job_ls.py +0 -0
  119. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_kill_records_first.py +0 -0
  120. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_legacy_shim.py +0 -0
  121. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_login.py +0 -0
  122. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_logs_unknown_pid.py +0 -0
  123. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_bake_flag.py +0 -0
  124. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_dag.py +0 -0
  125. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_default_image.py +0 -0
  126. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_window_flags.py +0 -0
  127. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_modal_compat.py +0 -0
  128. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_no_resume_gate.py +0 -0
  129. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_progress_narration.py +0 -0
  130. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_provider_env_default.py +0 -0
  131. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_queued_reason_render.py +0 -0
  132. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_rate_limit_ride.py +0 -0
  133. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_rebuild_respawn.py +0 -0
  134. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_resume_volume_validation.py +0 -0
  135. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_ride_cap.py +0 -0
  136. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_migration.py +0 -0
  137. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_volume_collision.py +0 -0
  138. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_wait_flags.py +0 -0
  139. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_secret_resolution.py +0 -0
  140. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_session_truth.py +0 -0
  141. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sessions_live_statuses.py +0 -0
  142. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sessions_point_lookup.py +0 -0
  143. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_setup_cmd.py +0 -0
  144. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_env.py +0 -0
  145. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_interrupt.py +0 -0
  146. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_volumes.py +0 -0
  147. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_skus.py +0 -0
  148. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_spend.py +0 -0
  149. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_stop_refused_classification.py +0 -0
  150. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_boost.py +0 -0
  151. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_cost_optin.py +0 -0
  152. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_harvest.py +0 -0
  153. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_lifecycle_ux.py +0 -0
  154. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_retry_and_kill_truth.py +0 -0
  155. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_status_view.py +0 -0
  156. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweeps_binding.py +0 -0
  157. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_tunnel.py +0 -0
  158. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_types.py +0 -0
  159. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_upload_s3_path.py +0 -0
  160. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_v2_connect.py +0 -0
  161. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_v2_connect_billing.py +0 -0
  162. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_cli_hardening.py +0 -0
  163. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_download_delete.py +0 -0
  164. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_preflight.py +0 -0
  165. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_resolution.py +0 -0
  166. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_wait.py +0 -0
  167. {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_wait.py +0 -0
@@ -6,6 +6,50 @@ versions follow [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] - 2026-09-22
10
+
11
+ ### Fixed
12
+ - `fcloud credits buy` no longer turns auto-refill on by default. It used to
13
+ consent on behalf of anyone who had never consented before, so buying less
14
+ than the auto-refill threshold enrolled the account and then charged the
15
+ saved card for the full top-up seconds later — `buy 10` against the default
16
+ $25 threshold cost $110. Consent is now opt-in via `--autorefill`, and
17
+ `--autorefill` also turns the setting back on for an account that had it
18
+ off, which consent alone cannot do. `--no-autorefill` is still accepted and
19
+ is now the default; passing both is an error.
20
+ - Before a purchase that will trigger a top-up, the CLI now says so and names
21
+ the amount. What triggers one is the balance a purchase *leaves behind*,
22
+ not its size, and the refill is sized to clear the threshold — so $10
23
+ against a $200 threshold charges the card a further $195, not the $50 the
24
+ setting says. Both numbers are worked out and printed before anything is
25
+ charged, including under `--yes`, and including for an account that was
26
+ already armed.
27
+ - `fcloud credits` no longer claims auto-refill "turns on with your first
28
+ purchase". It turns on with a purchase made with `--autorefill`, and the
29
+ status line now says so and names that command.
30
+ - `fcloud credits buy` no longer sends buyers to a dead address after paying.
31
+ The return URL was built from a domain baked in at release time, which
32
+ outlived its DNS record: the charge succeeded and the browser landed on a
33
+ 404. The CLI now names no return address and the dispatcher supplies one
34
+ from its own allowlist, which redeploys with the web app instead of going
35
+ stale in a published wheel. Setting `FCLOUD_WEB_URL` returns you to
36
+ `/dashboard`, the page that reads the result, rather than to
37
+ `/dashboard/billing`, which ignores it.
38
+
39
+ ### Changed
40
+ - `resolve_web_url()` returns `None` when no web URL is configured, and
41
+ `DEFAULT_WEB_URL` is gone. `Client.start_credit_checkout()` takes
42
+ `success_url` and `cancel_url` as optional and omits them from the request
43
+ when unset. Set `FCLOUD_WEB_URL` only to override where a buyer is returned
44
+ to. **Needs a dispatcher carrying the matching server change**; against an
45
+ older one, set `FCLOUD_WEB_URL` or buy from the dashboard.
46
+
47
+ ### Added
48
+ - `fcloud.billing_types.predicted_topup_micros()` — what auto-refill would
49
+ charge once a given purchase has posted. Mirrors the dispatcher's own
50
+ `topUpAmount` and daily-cap clamp so a confirmation can name the second
51
+ charge before the card is saved.
52
+
9
53
  ## [0.3.0] - 2026-09-16
10
54
 
11
55
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcloud-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python SDK and CLI for the fcloud GPU compute platform
5
5
  Author: fcloud
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fcloud-sdk"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Python SDK and CLI for the fcloud GPU compute platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -41,6 +41,12 @@ NOT_ARMED_REASONS = ("driver_off", "disabled", "not_consented", "no_card")
41
41
  PURCHASE_MIN_MICROS = 10_000_000
42
42
  PURCHASE_MAX_MICROS = 5_000_000_000
43
43
 
44
+ # The smallest refill the scheduler will mint (billing_autorefill.go:29) and
45
+ # the ledger's unit ratio (billing_topup_contract.go:129). Both are inputs to
46
+ # predicted_topup_micros below.
47
+ AUTOREFILL_MIN_AMOUNT_MICROS = 10_000_000
48
+ MICROS_PER_CENT = 10_000
49
+
44
50
  # Ledger paging (billing_ledger_handlers.go).
45
51
  LEDGER_PAGE_DEFAULT = 50
46
52
  LEDGER_PAGE_MAX = 200
@@ -155,6 +161,57 @@ class CreditBalance:
155
161
  )
156
162
 
157
163
 
164
+ def _whole_cents(micros: int) -> int:
165
+ """Floor to a whole cent the way the dispatcher does.
166
+
167
+ topUpWholeCents is ``micros - micros%microsPerCent`` in Go, whose ``%``
168
+ truncates toward zero. Python's floors, which differs for negatives, so
169
+ spell the truncation out rather than inherit the wrong one.
170
+ """
171
+ remainder = abs(micros) % MICROS_PER_CENT
172
+ return micros - remainder if micros >= 0 else micros + remainder
173
+
174
+
175
+ def predicted_topup_micros(balance: CreditBalance, purchase_micros: int = 0) -> int:
176
+ """What auto-refill charges once ``purchase_micros`` has posted, or 0 for nothing.
177
+
178
+ Mirrors topUpAmount (billing_topup.go:169) and topUpWithinDailyCap
179
+ (billing_topup_schedule.go:94) so a confirmation can name the second
180
+ charge *before* the card is saved. Keep the two in step.
181
+
182
+ This is the charge auto-refill would make, not a promise that it will.
183
+ Two known imprecisions, both erring toward over-statement, which is the
184
+ safe direction for a consent prompt:
185
+
186
+ - ``charged_24h_usd_micros`` is left at zero by the dispatcher unless the
187
+ account's own three legs already pass (billing_balance_view.go:108-120),
188
+ so for an account that is not armed yet the daily-cap clamp is skipped
189
+ and this is an upper bound.
190
+ - A deployment whose top-up driver is off charges nothing at all. That is
191
+ deliberately ignored: the driver can be re-armed, and what the prompt
192
+ describes is a standing order, not this particular hour.
193
+ """
194
+ autorefill = balance.autorefill
195
+ if autorefill.threshold_usd_micros <= 0:
196
+ return 0
197
+ # The trigger is the balance this purchase leaves behind, not the size of
198
+ # the purchase — and it is a strict `<`, so landing exactly on the
199
+ # threshold does not fire.
200
+ available = balance.available_usd_micros + purchase_micros
201
+ if available >= autorefill.threshold_usd_micros:
202
+ return 0
203
+ # Enough to clear the threshold when the hole is deeper than the
204
+ # configured amount, which is how a $100 refill becomes a $195 one.
205
+ want = max(autorefill.amount_usd_micros,
206
+ autorefill.threshold_usd_micros - available)
207
+ want = _whole_cents(min(want, PURCHASE_MAX_MICROS))
208
+ if autorefill.daily_cap_usd_micros > 0:
209
+ headroom = autorefill.daily_cap_usd_micros - autorefill.charged_24h_usd_micros
210
+ if headroom < want:
211
+ want = _whole_cents(headroom)
212
+ return want if want >= AUTOREFILL_MIN_AMOUNT_MICROS else 0
213
+
214
+
158
215
  @dataclass
159
216
  class CreditEntry:
160
217
  """One signed money movement on the ledger (member-redacted view).
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import sys
12
12
 
13
+ from fcloud.billing_types import CreditBalance, predicted_topup_micros
13
14
  from fcloud.cli import output
14
15
  from fcloud.cli.common import _extract_flag
15
16
  from fcloud.cli.credits_buy import await_purchase, open_checkout, start_checkout
@@ -29,12 +30,19 @@ at zero new work is refused until you top up.
29
30
 
30
31
  fcloud credits # balance, auto-refill, card
31
32
  fcloud credits buy 25 # buy $25 via hosted checkout
33
+ fcloud credits buy 25 --autorefill # ...and let a saved card top you up
32
34
  fcloud credits autorefill on # let a saved card top you up
33
35
  fcloud credits autorefill set --threshold 25 --amount 100
34
36
  fcloud credits statement --limit 20 # recent ledger rows
35
37
 
36
38
  `available` is the number that matters: it is your posted balance minus
37
- usage that has been metered but not yet billed to the ledger."""
39
+ usage that has been metered but not yet billed to the ledger.
40
+
41
+ Buying is a one-off: `buy` never turns auto-refill on unless you pass
42
+ `--autorefill`. What triggers a top-up is the balance a purchase leaves
43
+ behind, not its size — buy too little to clear your own threshold and
44
+ auto-refill charges the card again straight away. The confirmation works
45
+ that out and names the amount before anything is charged."""
38
46
 
39
47
 
40
48
 
@@ -71,18 +79,37 @@ def _cmd_balance(args: list[str]) -> None:
71
79
 
72
80
  def _cmd_buy(args: list[str]) -> None:
73
81
  assume_yes, rest = _take_bool(args, "--yes")
82
+ want_autorefill, rest = _take_bool(rest, "--autorefill")
74
83
  no_autorefill, rest = _take_bool(rest, "--no-autorefill")
75
84
  if len(rest) != 1:
76
- output._fail("Usage: fcloud credits buy <usd> [--yes] [--no-autorefill]",
85
+ output._fail(
86
+ "Usage: fcloud credits buy <usd> [--yes] [--autorefill|--no-autorefill]",
87
+ code="usage", exit_code=2)
88
+ if want_autorefill and no_autorefill:
89
+ output._fail("--autorefill and --no-autorefill contradict each other",
77
90
  code="usage", exit_code=2)
78
91
  micros = _parse_usd(rest[0])
79
92
  client = _client()
80
93
  balance = client.credit_balance()
81
- consent = not no_autorefill and not balance.autorefill.consented_at
82
- if not assume_yes and not _confirm_purchase(micros, consent):
94
+ # Consent is opt-IN. It used to default on for anyone who had never
95
+ # consented, which meant `buy 10` enrolled you at the default $25
96
+ # threshold and a $100 top-up fired seconds later, against a card you
97
+ # had only just saved. Buying credit and standing an order to buy more
98
+ # are different decisions; only --autorefill makes the second one.
99
+ need_consent, need_enable = _autorefill_changes(balance, want_autorefill)
100
+ # Printed even under --yes: it names a second charge, and consenting to
101
+ # the purchase in advance is not the same as knowing about that one.
102
+ notice = _autorefill_notice(micros, balance, want_autorefill)
103
+ if notice:
104
+ print(notice)
105
+ if not assume_yes and not _ask_yes_no(f"Buy {usd(micros)} of credit?"):
83
106
  print("Nothing was charged.")
84
107
  return
85
- start = start_checkout(client, micros, consent)
108
+ # Before checkout, not after: enabled-but-unconsented is simply not
109
+ # armed, so a checkout the buyer abandons leaves nothing standing.
110
+ if need_enable:
111
+ client.set_autorefill(enabled=True)
112
+ start = start_checkout(client, micros, need_consent)
86
113
  open_checkout(start.url)
87
114
  await_purchase(client, start.purchase_id, balance.balance_usd_micros)
88
115
 
@@ -195,11 +222,52 @@ def _parse_usd(raw: str, *, flag: str = "") -> int:
195
222
  return micros
196
223
 
197
224
 
198
- def _confirm_purchase(micros: int, consent: bool) -> bool:
199
- line = f"Buy {usd(micros)} of credit?"
200
- if consent:
201
- line += " Auto-refill will be turned ON."
202
- return _ask_yes_no(line)
225
+ def _autorefill_changes(balance: CreditBalance, want: bool) -> tuple[bool, bool]:
226
+ """What `--autorefill` still has to change: (stamp consent, set enabled).
227
+
228
+ Two separate rows, and a purchase only reaches the first: the dispatcher
229
+ stamps autorefill_consented_at from the Checkout metadata and never
230
+ writes autorefill_enabled (billing_webhook_purchase.go:225). Consent is
231
+ not settable any other way, and the enabled flag is not settable by a
232
+ purchase — so an account that ran `autorefill off` needs a settings
233
+ write too, or the flag promises an arming that never happens. Hence the
234
+ pair, rather than one "turn it on" boolean.
235
+ """
236
+ return (want and not balance.autorefill.consented_at,
237
+ want and not balance.autorefill.enabled)
238
+
239
+
240
+ def _autorefill_notice(micros: int, balance: CreditBalance, want: bool) -> str:
241
+ """What this purchase does to auto-refill, and what that will cost.
242
+
243
+ The second charge is the one people get wrong. It fires on the balance
244
+ the purchase leaves behind rather than on the purchase itself, and it is
245
+ sized to clear the threshold, so it can be several times the configured
246
+ amount. Say both before the card is saved, not after it is charged.
247
+ """
248
+ autorefill = balance.autorefill
249
+ need_consent, need_enable = _autorefill_changes(balance, want)
250
+ parts: list[str] = []
251
+ if need_consent or need_enable:
252
+ parts.append(
253
+ f"Auto-refill will be turned ON: {usd(autorefill.amount_usd_micros)} "
254
+ f"whenever your available balance falls below "
255
+ f"{usd(autorefill.threshold_usd_micros)}.")
256
+ elif want:
257
+ parts.append("Auto-refill is already on; this purchase does not change it.")
258
+ # Armed once this settles: it already was, or this purchase supplies the
259
+ # consent and the card that arm it.
260
+ armed_after = autorefill.armed or need_consent or need_enable
261
+ topup = predicted_topup_micros(balance, micros) if armed_after else 0
262
+ if topup:
263
+ after = balance.available_usd_micros + micros
264
+ parts.append(
265
+ f"Your available balance after this purchase is {usd(after)}, which is "
266
+ f"below {usd(autorefill.threshold_usd_micros)}, so auto-refill will "
267
+ f"charge your card a further {usd(topup)} within the hour — run "
268
+ f"`fcloud credits autorefill set --threshold` first if that is not "
269
+ f"what you want.")
270
+ return "\n".join(parts)
203
271
 
204
272
 
205
273
  def _ask_yes_no(question: str) -> bool:
@@ -27,12 +27,21 @@ _PURCHASE_POLL_INTERVAL = 3
27
27
 
28
28
  def start_checkout(client: Client, micros: int, consent: bool):
29
29
  """Mint the hosted Checkout session, naming the remedy on refusals."""
30
+ # No FCLOUD_WEB_URL means we name no return address and the dispatcher
31
+ # fills one from its allowlist. That is the preferred path: it is the
32
+ # only copy of the dashboard's address that redeploys with it.
30
33
  base = resolve_web_url()
31
34
  try:
32
35
  return client.start_credit_checkout(
33
36
  amount_usd_micros=micros,
34
- success_url=f"{base}/dashboard/billing?purchase=success",
35
- cancel_url=f"{base}/dashboard/billing?purchase=cancelled",
37
+ # The same paths the dispatcher would have chosen
38
+ # (billing_checkout_handlers.go:101-104). /dashboard is the page
39
+ # that reads ?purchase=; /dashboard/billing does not, so a buyer
40
+ # sent there watches nothing happen. Stripe substitutes the
41
+ # session id.
42
+ success_url=(f"{base}/dashboard?purchase={{CHECKOUT_SESSION_ID}}"
43
+ if base else None),
44
+ cancel_url=f"{base}/dashboard?purchase=cancelled" if base else None,
36
45
  autorefill_consent=consent,
37
46
  )
38
47
  except FcloudError as exc:
@@ -40,12 +49,21 @@ def start_checkout(client: Client, micros: int, consent: bool):
40
49
  code=str(exc) or "checkout_failed")
41
50
 
42
51
 
43
- def _checkout_error_message(code: str, base: str) -> str:
52
+ def _checkout_error_message(code: str, base: str | None) -> str:
44
53
  """Turn the dispatcher's bare checkout codes into a recovery sentence."""
45
54
  if code == "bad_return_url":
55
+ if base is None:
56
+ return ("this deployment rejected its own checkout return address "
57
+ "— an operator should check FCLOUD_CHECKOUT_RETURN_ORIGINS, "
58
+ "or buy credit from the dashboard directly")
46
59
  return (f"this deployment does not allow {base} as a checkout return "
47
- f"address — set FCLOUD_WEB_URL to your dashboard's origin, "
60
+ f"address — unset FCLOUD_WEB_URL to let the server choose, "
48
61
  f"or buy credit from the dashboard directly")
62
+ if code == "return_url_required":
63
+ return ("this deployment has no checkout return origin configured, so "
64
+ "it cannot say where to send you after paying — an operator "
65
+ "should set FCLOUD_CHECKOUT_RETURN_ORIGINS, or set "
66
+ "FCLOUD_WEB_URL here to name your dashboard yourself")
49
67
  if code == "billing_mode_not_prepaid":
50
68
  return ("this deployment still bills by invoice, so there is no "
51
69
  "credit to buy yet")
@@ -15,7 +15,7 @@ from fcloud.billing_types import AutorefillView, CreditBalance, LedgerPage
15
15
  _NOT_ARMED_SENTENCES = {
16
16
  "driver_off": "auto-refill is paused platform-wide right now",
17
17
  "disabled": "auto-refill is off",
18
- "not_consented": "auto-refill turns on with your first purchase",
18
+ "not_consented": "you have not consented to it yet",
19
19
  "no_card": "no card is saved, so nothing can top you up",
20
20
  }
21
21
 
@@ -82,6 +82,10 @@ def print_autorefill(autorefill: AutorefillView) -> None:
82
82
  print(f"Auto-refill: OFF — {reason}")
83
83
  if autorefill.not_armed_reason in ("disabled", ""):
84
84
  print(" Turn it on: fcloud credits autorefill on")
85
+ elif autorefill.not_armed_reason == "not_consented":
86
+ # Consent is stamped by a purchase and by nothing else — no settings
87
+ # write can grant it — so this is the only remedy there is.
88
+ print(" Consent with a purchase: fcloud credits buy 25 --autorefill")
85
89
  elif autorefill.not_armed_reason == "no_card":
86
90
  print(" Add a card with your first purchase: fcloud credits buy 25")
87
91
 
@@ -336,7 +336,8 @@ SPECS: dict[str, CmdSpec] = {
336
336
  # credits <balance|buy|autorefill|statement> — the prepaid wallet; the
337
337
  # widest positional shape is `credits autorefill set`.
338
338
  "credits": CmdSpec("credits", (
339
- Flag(("--yes",), BOOL), Flag(("--no-autorefill",), BOOL),
339
+ Flag(("--yes",), BOOL), Flag(("--autorefill",), BOOL),
340
+ Flag(("--no-autorefill",), BOOL),
340
341
  Flag(("--threshold",)), Flag(("--amount",)), Flag(("--daily-cap",)),
341
342
  Flag(("--limit",)),
342
343
  ), tail=POSITIONAL, max_positionals=2),
@@ -82,24 +82,31 @@ class ClientBillingMixin:
82
82
  self,
83
83
  *,
84
84
  amount_usd_micros: int,
85
- success_url: str,
86
- cancel_url: str,
85
+ success_url: str | None = None,
86
+ cancel_url: str | None = None,
87
87
  autorefill_consent: bool = False,
88
88
  ) -> CheckoutStart:
89
89
  """Open a hosted Stripe Checkout session for a credit purchase.
90
90
 
91
91
  Returns the URL to send the buyer to; the credit lands on the
92
- ledger when Stripe's webhook settles, not when this returns. The
93
- return URLs must be absolute https (http only for localhost) and,
94
- where the deployment restricts them, on an allowed origin.
92
+ ledger when Stripe's webhook settles, not when this returns.
93
+
94
+ Leave the return URLs unset to let the dispatcher supply them from
95
+ its own allowlist — the recommended path, since it cannot point at
96
+ a dashboard that has moved. Passing them keeps the old behaviour:
97
+ absolute https (http only for localhost) and, where the deployment
98
+ restricts them, on an allowed origin.
95
99
  """
100
+ body: dict[str, object] = {
101
+ "amount_usd_micros": int(amount_usd_micros),
102
+ "autorefill_consent": bool(autorefill_consent),
103
+ }
104
+ # Omitted, not empty: "" is how the server spells "caller named
105
+ # none", but sending the key at all reads as an attempt to set it.
106
+ if success_url:
107
+ body["success_url"] = success_url
108
+ if cancel_url:
109
+ body["cancel_url"] = cancel_url
96
110
  return CheckoutStart.from_response(self._post(
97
- "/v1/billing/checkout",
98
- {
99
- "amount_usd_micros": int(amount_usd_micros),
100
- "autorefill_consent": bool(autorefill_consent),
101
- "success_url": success_url,
102
- "cancel_url": cancel_url,
103
- },
104
- timeout=30,
111
+ "/v1/billing/checkout", body, timeout=30,
105
112
  ))
@@ -40,12 +40,13 @@ if TYPE_CHECKING:
40
40
 
41
41
  DEFAULT_URL = "https://fcloud-dispatcher.fly.dev"
42
42
 
43
- # The public web app, used only to build the return URLs a hosted Checkout
44
- # sends the buyer back to. The dispatcher validates those against its own
45
- # FCLOUD_CHECKOUT_RETURN_ORIGINS allowlist, so a deployment whose web app
46
- # lives elsewhere must either set FCLOUD_WEB_URL here or allowlist this one
47
- # — otherwise `fcloud credits buy` is refused with bad_return_url.
48
- DEFAULT_WEB_URL = "https://foomcloud.sh"
43
+ # There is deliberately no default web URL. This value only builds the
44
+ # return address a hosted Checkout sends the buyer back to, and a constant
45
+ # compiled into a published wheel cannot follow the web app when it moves:
46
+ # the previous default outlived its DNS record and dropped every CLI buyer
47
+ # on a 404 after their card had already been charged. The dispatcher holds
48
+ # the live answer in FCLOUD_CHECKOUT_RETURN_ORIGINS and fills the URLs in
49
+ # when we send none, so unset means "let the server say", not "guess".
49
50
 
50
51
  CONFIG_DIR = Path.home() / ".fcloud"
51
52
  # ponytail: legacy config dir; drop once users re-run `fcloud set_token`.
@@ -501,8 +502,13 @@ def _has_torch(packages: list[str]) -> bool:
501
502
  return any("torch" in p.lower() for p in packages)
502
503
 
503
504
 
504
- def resolve_web_url(explicit: str | None = None) -> str:
505
- """Return the public web app's base URL (no trailing slash).
505
+ def resolve_web_url(explicit: str | None = None) -> str | None:
506
+ """Return the configured web app base URL, or None if none is set.
507
+
508
+ None is a real answer, not a failure: the dispatcher fills the checkout
509
+ return URLs from its own allowlist when we send none, which is the only
510
+ copy of that address that cannot go stale. Override it only to point a
511
+ buyer at a different dashboard than the deployment's own.
506
512
 
507
513
  Unlike the dispatcher URL this is not a credential-bearing endpoint, so
508
514
  it is resolved from the plain environment without the dotenv/token
@@ -515,4 +521,4 @@ def resolve_web_url(explicit: str | None = None) -> str:
515
521
  value = os.environ.get(name, "").strip()
516
522
  if value:
517
523
  return value.rstrip("/")
518
- return DEFAULT_WEB_URL
524
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcloud-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python SDK and CLI for the fcloud GPU compute platform
5
5
  Author: fcloud
6
6
  License-Expression: Apache-2.0
@@ -85,6 +85,7 @@ src/fcloud_sdk.egg-info/top_level.txt
85
85
  src/foom/__init__.py
86
86
  tests/conftest.py
87
87
  tests/test_autoresearch.py
88
+ tests/test_billing_types.py
88
89
  tests/test_checkpoint_option.py
89
90
  tests/test_cli_args.py
90
91
  tests/test_cli_dispatch.py
@@ -0,0 +1,93 @@
1
+ """`predicted_topup_micros` against the dispatcher's own arithmetic.
2
+
3
+ Pure function, so these are unit tests: the point is that the SDK's mirror of
4
+ topUpAmount (billing_topup.go:169) and topUpWithinDailyCap
5
+ (billing_topup_schedule.go:94) stays in step with them. Every case below
6
+ names the server behaviour it pins.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fcloud.billing_types import CreditBalance, predicted_topup_micros
12
+
13
+
14
+ def _balance(available: int, **autorefill) -> CreditBalance:
15
+ """A prepaid balance whose auto-refill rule is armed unless said otherwise."""
16
+ rule = {
17
+ "enabled": True, "armed": True,
18
+ "threshold_usd_micros": 25_000_000,
19
+ "amount_usd_micros": 100_000_000,
20
+ "daily_cap_usd_micros": 500_000_000,
21
+ }
22
+ rule.update(autorefill)
23
+ return CreditBalance.from_response({
24
+ "billing_mode": "prepaid",
25
+ "balance_usd_micros": available,
26
+ "available_usd_micros": available,
27
+ "autorefill": rule,
28
+ })
29
+
30
+
31
+ def test_a_purchase_that_clears_the_threshold_charges_nothing() -> None:
32
+ assert predicted_topup_micros(_balance(40_000_000), 10_000_000) == 0
33
+
34
+
35
+ def test_landing_exactly_on_the_threshold_does_not_fire() -> None:
36
+ """The dispatcher skips on `available >= threshold`, not `>`."""
37
+ assert predicted_topup_micros(_balance(15_000_000), 10_000_000) == 0
38
+
39
+
40
+ def test_a_cent_below_the_threshold_fires() -> None:
41
+ assert predicted_topup_micros(_balance(14_990_000), 10_000_000) == 100_000_000
42
+
43
+
44
+ def test_the_incident_case_is_seen_from_a_negative_balance() -> None:
45
+ """-$5 available, buy $25 against a $25 threshold: $20 after, so it fires.
46
+
47
+ Comparing the purchase against the threshold instead would call this
48
+ clear ($25 is not below $25) and say nothing, which is the bug.
49
+ """
50
+ assert predicted_topup_micros(_balance(-5_000_000), 25_000_000) == 100_000_000
51
+
52
+
53
+ def test_the_charge_grows_to_clear_a_threshold_deeper_than_the_amount() -> None:
54
+ """max(amount, threshold - available): $200 threshold, $5 left, $50 amount."""
55
+ balance = _balance(-5_000_000, threshold_usd_micros=200_000_000,
56
+ amount_usd_micros=50_000_000)
57
+ assert predicted_topup_micros(balance, 10_000_000) == 195_000_000
58
+
59
+
60
+ def test_the_charge_is_capped_at_one_purchase_ceiling() -> None:
61
+ balance = _balance(-10_000_000_000, threshold_usd_micros=1_000_000_000,
62
+ daily_cap_usd_micros=0)
63
+ assert predicted_topup_micros(balance, 0) == 5_000_000_000
64
+
65
+
66
+ def test_the_charge_is_floored_to_whole_cents() -> None:
67
+ """A fraction of a cent could never settle, so the dispatcher floors it."""
68
+ balance = _balance(1_000, threshold_usd_micros=100_001_000,
69
+ amount_usd_micros=10_000_000)
70
+ assert predicted_topup_micros(balance, 0) == 100_000_000
71
+
72
+
73
+ def test_daily_cap_headroom_clamps_the_charge() -> None:
74
+ balance = _balance(0, daily_cap_usd_micros=120_000_000,
75
+ charged_24h_usd_micros=60_000_000)
76
+ assert predicted_topup_micros(balance, 0) == 60_000_000
77
+
78
+
79
+ def test_headroom_below_the_minimum_refill_charges_nothing() -> None:
80
+ balance = _balance(0, daily_cap_usd_micros=105_000_000,
81
+ charged_24h_usd_micros=100_000_000)
82
+ assert predicted_topup_micros(balance, 0) == 0
83
+
84
+
85
+ def test_an_exhausted_cap_charges_nothing() -> None:
86
+ balance = _balance(0, daily_cap_usd_micros=100_000_000,
87
+ charged_24h_usd_micros=100_000_000)
88
+ assert predicted_topup_micros(balance, 0) == 0
89
+
90
+
91
+ def test_no_threshold_means_no_prediction() -> None:
92
+ """An account the dispatcher has never given a rule to."""
93
+ assert predicted_topup_micros(_balance(0, threshold_usd_micros=0), 0) == 0
@@ -31,6 +31,10 @@ class _Resp:
31
31
  class _RecordingTransport:
32
32
  def __init__(self) -> None:
33
33
  self.calls: list[tuple[str, str]] = []
34
+ # Bodies live alongside `calls` rather than inside it: the tuples
35
+ # below are asserted on by name in several tests, and widening them
36
+ # would churn every one.
37
+ self.bodies: list[tuple[str, dict]] = []
34
38
 
35
39
  def get(self, url, headers=None, timeout=None) -> _Resp:
36
40
  self.calls.append(("GET", url))
@@ -42,6 +46,10 @@ class _RecordingTransport:
42
46
 
43
47
  def post(self, url, json=None, headers=None, timeout=None) -> _Resp:
44
48
  self.calls.append(("POST", url))
49
+ self.bodies.append((url, json or {}))
50
+ if "/billing/checkout" in url:
51
+ return _Resp({"url": "https://checkout.stripe.com/c/pay/cs_1",
52
+ "purchase_id": "pur_1"})
45
53
  return _Resp({})
46
54
 
47
55
  def delete(self, url, headers=None, timeout=None) -> _Resp:
@@ -87,3 +95,34 @@ def test_server_state_reads_issue_http(call, want_method, want_url):
87
95
  assert not client._direct.method_calls, ( # noqa: SLF001
88
96
  f"bridge consulted for server state: {client._direct.method_calls}"
89
97
  )
98
+
99
+
100
+ def _checkout_body(transport: _RecordingTransport) -> dict:
101
+ return [b for u, b in transport.bodies if "/billing/checkout" in u][0]
102
+
103
+
104
+ def test_checkout_omits_the_return_urls_rather_than_sending_them_empty():
105
+ """The dispatcher reads an absent URL as "caller named none".
106
+
107
+ An empty string is not the same thing — validateReturnURL rejects it
108
+ outright — so the keys have to be missing from the body, which only a
109
+ transport-level assertion can see.
110
+ """
111
+ client, transport = _real_client()
112
+ client.start_credit_checkout(amount_usd_micros=25_000_000)
113
+ body = _checkout_body(transport)
114
+ assert "success_url" not in body
115
+ assert "cancel_url" not in body
116
+ assert body["amount_usd_micros"] == 25_000_000
117
+
118
+
119
+ def test_checkout_sends_the_return_urls_when_it_has_them():
120
+ client, transport = _real_client()
121
+ client.start_credit_checkout(
122
+ amount_usd_micros=25_000_000,
123
+ success_url="https://dash.example/dashboard?purchase=ok",
124
+ cancel_url="https://dash.example/dashboard?purchase=cancelled",
125
+ )
126
+ body = _checkout_body(transport)
127
+ assert body["success_url"] == "https://dash.example/dashboard?purchase=ok"
128
+ assert body["cancel_url"] == "https://dash.example/dashboard?purchase=cancelled"