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.
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/CHANGELOG.md +44 -0
- {fcloud_sdk-0.3.0/src/fcloud_sdk.egg-info → fcloud_sdk-0.3.1}/PKG-INFO +1 -1
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/pyproject.toml +1 -1
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/billing_types.py +57 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits.py +78 -10
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits_buy.py +22 -4
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/credits_render.py +5 -1
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli_args.py +2 -1
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_billing.py +20 -13
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/config.py +15 -9
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1/src/fcloud_sdk.egg-info}/PKG-INFO +1 -1
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/SOURCES.txt +1 -0
- fcloud_sdk-0.3.1/tests/test_billing_types.py +93 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_http_contract.py +39 -0
- fcloud_sdk-0.3.1/tests/test_credits_cli.py +450 -0
- fcloud_sdk-0.3.0/tests/test_credits_cli.py +0 -237
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/CONTRIBUTING.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/LICENSE +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/MANIFEST.in +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/NOTICE +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/README.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/SKILL.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/setup.cfg +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/SKILL.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/__init__.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/__main__.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_direct_bridge.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_legacy_env.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/_modal_runner.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/SETUP.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/autoresearch/program.template.md +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/__init__.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/attach.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/autoresearch.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/common.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/config_cmd.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/console.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/context.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/exec_cmd.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/files.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/hardware.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/help.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/interactive.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/job.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/main.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/migration.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/mount.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/output.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/processes.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/registry.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/run.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sessions.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/setup.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/ssh.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_harvest.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/sweep_watch.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/usage.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/volume.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/cli/wait.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_projects.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_sessions.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/client_volumes.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/direct.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/errors.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/fileset.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/image.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/job.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/login.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_compat.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_objects.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/modal_run.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/providers/__init__.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/providers/requests_http.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/py.typed +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/session.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/setup_cmd.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/shell.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/sweeps.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/tunnel.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/types.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/v2_connect.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/version.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/volume_wait.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud/volumes.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/requires.txt +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/src/foom/__init__.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/conftest.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_autoresearch.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_checkpoint_option.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_args.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_dispatch.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_guards.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_cli_help.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_contracts_c.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_host_frames_contract.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_client_telemetry.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_config.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_config_cmd.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_credit_preflight.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_credits_errors.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_default_image.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_direct_bridge.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_direct_fake_host.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_dotenv_precedence.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_download_promotion_retry.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_download_volume_hint.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_emit_pid.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_exec_attach_retry.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_fileset.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_first_run_login.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_job.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_job_ls.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_kill_records_first.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_legacy_shim.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_login.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_logs_unknown_pid.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_bake_flag.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_dag.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_default_image.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_map_window_flags.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_modal_compat.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_no_resume_gate.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_progress_narration.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_provider_env_default.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_queued_reason_render.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_rate_limit_ride.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_rebuild_respawn.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_resume_volume_validation.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_ride_cap.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_migration.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_volume_collision.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_run_wait_flags.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_secret_resolution.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_session_truth.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sessions_live_statuses.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sessions_point_lookup.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_setup_cmd.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_env.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_interrupt.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_shell_volumes.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_skus.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_spend.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_stop_refused_classification.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_boost.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_cost_optin.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_harvest.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_lifecycle_ux.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_retry_and_kill_truth.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweep_status_view.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_sweeps_binding.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_tunnel.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_types.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_upload_s3_path.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_v2_connect.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_v2_connect_billing.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_cli_hardening.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_download_delete.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_preflight.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_resolution.py +0 -0
- {fcloud_sdk-0.3.0 → fcloud_sdk-0.3.1}/tests/test_volume_wait.py +0 -0
- {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
|
|
@@ -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(
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 —
|
|
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": "
|
|
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(("--
|
|
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.
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
#
|
|
44
|
-
# sends the buyer back to
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
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
|
|
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
|
|
524
|
+
return None
|
|
@@ -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"
|