bunq-cli 0.2.0__py3-none-any.whl
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.
- bunq_cli/__init__.py +0 -0
- bunq_cli/client.py +845 -0
- bunq_cli/commands/__init__.py +0 -0
- bunq_cli/commands/accounts.py +37 -0
- bunq_cli/commands/bunqme.py +75 -0
- bunq_cli/commands/callbacks.py +122 -0
- bunq_cli/commands/cards.py +122 -0
- bunq_cli/commands/drafts.py +79 -0
- bunq_cli/commands/events.py +37 -0
- bunq_cli/commands/login.py +131 -0
- bunq_cli/commands/oauth_client.py +113 -0
- bunq_cli/commands/payments.py +90 -0
- bunq_cli/commands/profiles.py +91 -0
- bunq_cli/commands/requests.py +122 -0
- bunq_cli/commands/sandbox.py +25 -0
- bunq_cli/commands/scheduled.py +108 -0
- bunq_cli/commands/statements.py +97 -0
- bunq_cli/config.py +180 -0
- bunq_cli/main.py +210 -0
- bunq_cli/oauth.py +176 -0
- bunq_cli/output.py +65 -0
- bunq_cli-0.2.0.dist-info/METADATA +68 -0
- bunq_cli-0.2.0.dist-info/RECORD +27 -0
- bunq_cli-0.2.0.dist-info/WHEEL +5 -0
- bunq_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bunq_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- bunq_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""OAuth client management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from bunq.sdk.model.generated.endpoint import (
|
|
7
|
+
OauthCallbackUrlApiObject,
|
|
8
|
+
OauthClientApiObject,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from bunq_cli.config import (
|
|
12
|
+
get_oauth_client,
|
|
13
|
+
load_config,
|
|
14
|
+
remove_oauth_client,
|
|
15
|
+
save_config,
|
|
16
|
+
set_oauth_client,
|
|
17
|
+
)
|
|
18
|
+
from bunq_cli.main import Context, pass_context, yes_option
|
|
19
|
+
from bunq_cli.oauth import REDIRECT_URI
|
|
20
|
+
from bunq_cli.output import echo_output
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group(name="oauth-client")
|
|
24
|
+
def oauth_client() -> None:
|
|
25
|
+
"""Manage the OAuth client for browser-based login."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@oauth_client.command("register")
|
|
29
|
+
@pass_context
|
|
30
|
+
def register(ctx: Context) -> None:
|
|
31
|
+
"""Register a new OAuth client and store its credentials.
|
|
32
|
+
|
|
33
|
+
Requires an active session (run 'bunq login --token' first); the OAuth
|
|
34
|
+
client is owned by the selected profile's user.
|
|
35
|
+
Automatically adds the CLI callback URL.
|
|
36
|
+
"""
|
|
37
|
+
client = ctx.get_client(write=True) # noqa: F841 -- ensures SDK context is loaded
|
|
38
|
+
|
|
39
|
+
config = load_config(ctx.config_path)
|
|
40
|
+
existing = get_oauth_client(config)
|
|
41
|
+
if existing.get("client_id"):
|
|
42
|
+
raise click.ClickException(
|
|
43
|
+
"An OAuth client is already registered. "
|
|
44
|
+
"Run 'bunq oauth-client delete' first to replace it."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Create the OAuth client
|
|
48
|
+
response = OauthClientApiObject.create(status="ACTIVE")
|
|
49
|
+
oauth_id = response.value
|
|
50
|
+
|
|
51
|
+
# Fetch the client details (client_id + secret)
|
|
52
|
+
detail = OauthClientApiObject.get(oauth_id).value
|
|
53
|
+
client_id = detail.client_id
|
|
54
|
+
client_secret = detail.secret
|
|
55
|
+
|
|
56
|
+
# Register the CLI callback URL
|
|
57
|
+
OauthCallbackUrlApiObject.create(oauth_id, url=REDIRECT_URI)
|
|
58
|
+
|
|
59
|
+
# Persist to config
|
|
60
|
+
config = set_oauth_client(config, client_id, client_secret, oauth_id, ctx.profile_name)
|
|
61
|
+
save_config(config, ctx.config_path)
|
|
62
|
+
|
|
63
|
+
if not ctx.quiet:
|
|
64
|
+
click.echo(f"OAuth client registered (ID: {oauth_id}).")
|
|
65
|
+
click.echo(f"Client ID: {client_id}")
|
|
66
|
+
click.echo(f"Callback URL: {REDIRECT_URI}")
|
|
67
|
+
click.echo()
|
|
68
|
+
click.echo("You can now log in with: bunq login")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@oauth_client.command("show")
|
|
72
|
+
@pass_context
|
|
73
|
+
def show(ctx: Context) -> None:
|
|
74
|
+
"""Show the stored OAuth client credentials."""
|
|
75
|
+
config = load_config(ctx.config_path)
|
|
76
|
+
oauth = get_oauth_client(config)
|
|
77
|
+
if not oauth.get("client_id"):
|
|
78
|
+
raise click.ClickException(
|
|
79
|
+
"No OAuth client registered. Run 'bunq oauth-client register' first."
|
|
80
|
+
)
|
|
81
|
+
data = {
|
|
82
|
+
"oauth_client_id": oauth["oauth_client_id"],
|
|
83
|
+
"client_id": oauth["client_id"],
|
|
84
|
+
"client_secret": oauth["client_secret"],
|
|
85
|
+
"callback_url": REDIRECT_URI,
|
|
86
|
+
"profile": oauth.get("profile", ""),
|
|
87
|
+
}
|
|
88
|
+
echo_output(data, fmt=ctx.output_format)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@oauth_client.command("delete")
|
|
92
|
+
@yes_option
|
|
93
|
+
@pass_context
|
|
94
|
+
def delete(ctx: Context, yes: bool) -> None:
|
|
95
|
+
"""Delete the OAuth client and remove stored credentials."""
|
|
96
|
+
config = load_config(ctx.config_path)
|
|
97
|
+
oauth = get_oauth_client(config)
|
|
98
|
+
oauth_id = oauth.get("oauth_client_id")
|
|
99
|
+
# The OAuth client lives under the user that registered it.
|
|
100
|
+
client = ctx.get_client(write=True, profile=oauth.get("profile")) # noqa: F841
|
|
101
|
+
ctx.confirm("Delete the OAuth client?", yes)
|
|
102
|
+
|
|
103
|
+
if oauth_id:
|
|
104
|
+
try:
|
|
105
|
+
OauthClientApiObject.update(oauth_id, status="CANCELLED")
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # best-effort: client may already be gone
|
|
108
|
+
|
|
109
|
+
config = remove_oauth_client(config)
|
|
110
|
+
save_config(config, ctx.config_path)
|
|
111
|
+
|
|
112
|
+
if not ctx.quiet:
|
|
113
|
+
click.echo("OAuth client deleted.")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Payments commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context, yes_option
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "created", "amount", "currency", "counterparty", "description"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def payments() -> None:
|
|
15
|
+
"""Manage payments."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@payments.command("list")
|
|
19
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
20
|
+
@click.option("--all", "fetch_all", is_flag=True, help="Fetch all pages.")
|
|
21
|
+
@click.option("--count", type=int, default=25, help="Number of results per page.")
|
|
22
|
+
@click.option("--older-id", type=int, default=None, help="Cursor: fetch results older than this ID.")
|
|
23
|
+
@click.option("--newer-id", type=int, default=None, help="Cursor: fetch results newer than this ID.")
|
|
24
|
+
@pass_context
|
|
25
|
+
def list_payments(
|
|
26
|
+
ctx: Context,
|
|
27
|
+
account_id: int | None,
|
|
28
|
+
fetch_all: bool,
|
|
29
|
+
count: int,
|
|
30
|
+
older_id: int | None,
|
|
31
|
+
newer_id: int | None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""List payments. Returns one page by default; use --all to fetch everything."""
|
|
34
|
+
client = ctx.get_client()
|
|
35
|
+
if fetch_all:
|
|
36
|
+
data = client.list_all_payments(account_id=account_id)
|
|
37
|
+
else:
|
|
38
|
+
data = client.list_payments(
|
|
39
|
+
account_id=account_id,
|
|
40
|
+
count=count,
|
|
41
|
+
older_id=older_id,
|
|
42
|
+
newer_id=newer_id,
|
|
43
|
+
)
|
|
44
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@payments.command("get")
|
|
48
|
+
@click.argument("id", type=int)
|
|
49
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
50
|
+
@pass_context
|
|
51
|
+
def get_payment(ctx: Context, id: int, account_id: int | None) -> None:
|
|
52
|
+
"""Get a single payment by ID."""
|
|
53
|
+
client = ctx.get_client()
|
|
54
|
+
data = client.get_payment(id, account_id=account_id)
|
|
55
|
+
echo_output(data, fmt=ctx.output_format)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@payments.command("create")
|
|
59
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
60
|
+
@click.option("--amount", required=True, help="Amount to send (e.g., 25.00).")
|
|
61
|
+
@click.option("--currency", default="EUR", help="Currency code (default: EUR).")
|
|
62
|
+
@click.option("--recipient-iban", required=True, help="Recipient IBAN.")
|
|
63
|
+
@click.option("--recipient-name", required=True, help="Recipient name.")
|
|
64
|
+
@click.option("--description", required=True, help="Payment description.")
|
|
65
|
+
@yes_option
|
|
66
|
+
@pass_context
|
|
67
|
+
def create_payment(
|
|
68
|
+
ctx: Context,
|
|
69
|
+
account_id: int | None,
|
|
70
|
+
amount: str,
|
|
71
|
+
currency: str,
|
|
72
|
+
recipient_iban: str,
|
|
73
|
+
recipient_name: str,
|
|
74
|
+
description: str,
|
|
75
|
+
yes: bool,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Create a payment."""
|
|
78
|
+
client = ctx.get_client(write=True)
|
|
79
|
+
source = f"account {account_id}" if account_id else "the primary account"
|
|
80
|
+
ctx.confirm(f"Pay {amount} {currency} to {recipient_name} ({recipient_iban}) from {source}?", yes)
|
|
81
|
+
payment_id = client.create_payment(
|
|
82
|
+
amount=amount,
|
|
83
|
+
currency=currency,
|
|
84
|
+
recipient_iban=recipient_iban,
|
|
85
|
+
recipient_name=recipient_name,
|
|
86
|
+
description=description,
|
|
87
|
+
account_id=account_id,
|
|
88
|
+
)
|
|
89
|
+
if not ctx.quiet:
|
|
90
|
+
click.echo(f"Payment created (ID: {payment_id}).")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Profile (client) management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from bunq_cli.config import (
|
|
10
|
+
load_config,
|
|
11
|
+
resolve_profile,
|
|
12
|
+
save_config,
|
|
13
|
+
validate_profile_name,
|
|
14
|
+
)
|
|
15
|
+
from bunq_cli.main import Context, pass_context
|
|
16
|
+
from bunq_cli.output import echo_output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def profiles() -> None:
|
|
21
|
+
"""Manage profiles (one per bunq login, e.g. per client)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@profiles.command("list")
|
|
25
|
+
@pass_context
|
|
26
|
+
def list_profiles(ctx: Context) -> None:
|
|
27
|
+
"""List profiles."""
|
|
28
|
+
config = load_config(ctx.config_path)
|
|
29
|
+
default = config.get("default_profile")
|
|
30
|
+
data = [
|
|
31
|
+
{
|
|
32
|
+
"default": "*" if name == default else "",
|
|
33
|
+
"name": name,
|
|
34
|
+
"display_name": p.get("display_name", ""),
|
|
35
|
+
"environment": "sandbox" if p.get("sandbox") else "production",
|
|
36
|
+
"read_only": p.get("read_only", False),
|
|
37
|
+
"logged_in": Path(p["context_path"]).expanduser().exists(),
|
|
38
|
+
}
|
|
39
|
+
for name, p in config.get("profiles", {}).items()
|
|
40
|
+
]
|
|
41
|
+
echo_output(data, fmt=ctx.output_format)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@profiles.command("use")
|
|
45
|
+
@click.argument("name")
|
|
46
|
+
@pass_context
|
|
47
|
+
def use_profile(ctx: Context, name: str) -> None:
|
|
48
|
+
"""Set the default profile."""
|
|
49
|
+
config = load_config(ctx.config_path)
|
|
50
|
+
resolve_profile(config, name)
|
|
51
|
+
config["default_profile"] = name
|
|
52
|
+
save_config(config, ctx.config_path)
|
|
53
|
+
if not ctx.quiet:
|
|
54
|
+
click.echo(f"Default profile is now '{name}'.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@profiles.command("rename")
|
|
58
|
+
@click.argument("old")
|
|
59
|
+
@click.argument("new")
|
|
60
|
+
@pass_context
|
|
61
|
+
def rename_profile(ctx: Context, old: str, new: str) -> None:
|
|
62
|
+
"""Rename a profile."""
|
|
63
|
+
validate_profile_name(new)
|
|
64
|
+
config = load_config(ctx.config_path)
|
|
65
|
+
resolve_profile(config, old)
|
|
66
|
+
if new in config["profiles"]:
|
|
67
|
+
raise click.ClickException(f"Profile '{new}' already exists.")
|
|
68
|
+
config["profiles"][new] = config["profiles"].pop(old)
|
|
69
|
+
if config.get("default_profile") == old:
|
|
70
|
+
config["default_profile"] = new
|
|
71
|
+
if config.get("oauth", {}).get("profile") == old:
|
|
72
|
+
config["oauth"]["profile"] = new
|
|
73
|
+
save_config(config, ctx.config_path)
|
|
74
|
+
if not ctx.quiet:
|
|
75
|
+
click.echo(f"Profile '{old}' renamed to '{new}'.")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@profiles.command("set")
|
|
79
|
+
@click.argument("name")
|
|
80
|
+
@click.option("--read-only/--read-write", default=None, help="Block or allow commands that change data.")
|
|
81
|
+
@pass_context
|
|
82
|
+
def set_profile_options(ctx: Context, name: str, read_only: bool | None) -> None:
|
|
83
|
+
"""Change profile settings."""
|
|
84
|
+
if read_only is None:
|
|
85
|
+
raise click.ClickException("Nothing to change. Use --read-only or --read-write.")
|
|
86
|
+
config = load_config(ctx.config_path)
|
|
87
|
+
resolve_profile(config, name)
|
|
88
|
+
config["profiles"][name]["read_only"] = read_only
|
|
89
|
+
save_config(config, ctx.config_path)
|
|
90
|
+
if not ctx.quiet:
|
|
91
|
+
click.echo(f"Profile '{name}' is now {'read-only' if read_only else 'read-write'}.")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Payment request commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context, yes_option
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "created", "amount", "currency", "counterparty", "description", "status"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def requests() -> None:
|
|
15
|
+
"""Manage payment requests."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@requests.command("list")
|
|
19
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
20
|
+
@pass_context
|
|
21
|
+
def list_requests(ctx: Context, account_id: int | None) -> None:
|
|
22
|
+
"""List outgoing payment requests."""
|
|
23
|
+
client = ctx.get_client()
|
|
24
|
+
data = client.list_requests(account_id=account_id)
|
|
25
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@requests.command("get")
|
|
29
|
+
@click.argument("id", type=int)
|
|
30
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
31
|
+
@pass_context
|
|
32
|
+
def get_request(ctx: Context, id: int, account_id: int | None) -> None:
|
|
33
|
+
"""Get a single outgoing payment request."""
|
|
34
|
+
client = ctx.get_client()
|
|
35
|
+
data = client.get_request(id, account_id=account_id)
|
|
36
|
+
echo_output(data, fmt=ctx.output_format)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@requests.command("create")
|
|
40
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
41
|
+
@click.option("--amount", required=True, help="Amount to request (e.g., 35.00).")
|
|
42
|
+
@click.option("--currency", default="EUR", help="Currency code (default: EUR).")
|
|
43
|
+
@click.option("--counterparty-email", default=None, help="Recipient email address.")
|
|
44
|
+
@click.option("--counterparty-phone", default=None, help="Recipient phone number (e.g., +31612345678).")
|
|
45
|
+
@click.option("--description", required=True, help="Request description.")
|
|
46
|
+
@pass_context
|
|
47
|
+
def create_request(
|
|
48
|
+
ctx: Context,
|
|
49
|
+
account_id: int | None,
|
|
50
|
+
amount: str,
|
|
51
|
+
currency: str,
|
|
52
|
+
counterparty_email: str | None,
|
|
53
|
+
counterparty_phone: str | None,
|
|
54
|
+
description: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Create a payment request."""
|
|
57
|
+
if counterparty_email:
|
|
58
|
+
cp_type, cp_value = "EMAIL", counterparty_email
|
|
59
|
+
elif counterparty_phone:
|
|
60
|
+
cp_type, cp_value = "PHONE_NUMBER", counterparty_phone
|
|
61
|
+
else:
|
|
62
|
+
raise click.ClickException("Provide --counterparty-email or --counterparty-phone.")
|
|
63
|
+
|
|
64
|
+
client = ctx.get_client(write=True)
|
|
65
|
+
request_id = client.create_request(
|
|
66
|
+
amount=amount,
|
|
67
|
+
currency=currency,
|
|
68
|
+
counterparty_type=cp_type,
|
|
69
|
+
counterparty_value=cp_value,
|
|
70
|
+
description=description,
|
|
71
|
+
account_id=account_id,
|
|
72
|
+
)
|
|
73
|
+
if not ctx.quiet:
|
|
74
|
+
click.echo(f"Payment request created (ID: {request_id}).")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@requests.command("revoke")
|
|
78
|
+
@click.argument("id", type=int)
|
|
79
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
80
|
+
@pass_context
|
|
81
|
+
def revoke_request(ctx: Context, id: int, account_id: int | None) -> None:
|
|
82
|
+
"""Revoke a pending payment request."""
|
|
83
|
+
client = ctx.get_client(write=True)
|
|
84
|
+
client.revoke_request(id, account_id=account_id)
|
|
85
|
+
if not ctx.quiet:
|
|
86
|
+
click.echo(f"Request {id} revoked.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@requests.command("incoming")
|
|
90
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
91
|
+
@pass_context
|
|
92
|
+
def list_incoming(ctx: Context, account_id: int | None) -> None:
|
|
93
|
+
"""List incoming payment requests."""
|
|
94
|
+
client = ctx.get_client()
|
|
95
|
+
data = client.list_incoming_requests(account_id=account_id)
|
|
96
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@requests.command("accept")
|
|
100
|
+
@click.argument("id", type=int)
|
|
101
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
102
|
+
@yes_option
|
|
103
|
+
@pass_context
|
|
104
|
+
def accept_request(ctx: Context, id: int, account_id: int | None, yes: bool) -> None:
|
|
105
|
+
"""Accept (and pay) an incoming payment request."""
|
|
106
|
+
client = ctx.get_client(write=True)
|
|
107
|
+
ctx.confirm(f"Accept and pay incoming request {id}?", yes)
|
|
108
|
+
client.accept_request(id, account_id=account_id)
|
|
109
|
+
if not ctx.quiet:
|
|
110
|
+
click.echo(f"Request {id} accepted.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@requests.command("reject")
|
|
114
|
+
@click.argument("id", type=int)
|
|
115
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID.")
|
|
116
|
+
@pass_context
|
|
117
|
+
def reject_request(ctx: Context, id: int, account_id: int | None) -> None:
|
|
118
|
+
"""Reject an incoming payment request."""
|
|
119
|
+
client = ctx.get_client(write=True)
|
|
120
|
+
client.reject_request(id, account_id=account_id)
|
|
121
|
+
if not ctx.quiet:
|
|
122
|
+
click.echo(f"Request {id} rejected.")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Sandbox helper commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.client import create_sandbox_user
|
|
8
|
+
from bunq_cli.commands.login import login
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def sandbox() -> None:
|
|
13
|
+
"""Sandbox environment helpers."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@sandbox.command("create-user")
|
|
17
|
+
@click.option("--login", "login_profile", metavar="PROFILE", default=None, help="Log in with the new user under this profile name.")
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def create_user(click_ctx: click.Context, login_profile: str | None) -> None:
|
|
20
|
+
"""Create a sandbox test user and print its API key."""
|
|
21
|
+
api_key = create_sandbox_user()
|
|
22
|
+
if login_profile:
|
|
23
|
+
click_ctx.invoke(login, profile_name=login_profile, token=api_key, sandbox=True)
|
|
24
|
+
else:
|
|
25
|
+
click.echo(api_key)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Scheduled payment commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from bunq_cli.main import Context, pass_context, yes_option
|
|
10
|
+
from bunq_cli.output import echo_output
|
|
11
|
+
|
|
12
|
+
LIST_COLUMNS = ["id", "status", "amount", "currency", "counterparty", "description", "every", "next"]
|
|
13
|
+
UNITS = ["ONCE", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"]
|
|
14
|
+
DATETIME_FORMATS = ["%Y-%m-%d", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_api_time(value: datetime) -> str:
|
|
18
|
+
# bunq expects UTC; naive input is treated as local time.
|
|
19
|
+
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group("scheduled-payments")
|
|
23
|
+
def scheduled() -> None:
|
|
24
|
+
"""Manage scheduled (recurring) payments."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@scheduled.command("list")
|
|
28
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
29
|
+
@pass_context
|
|
30
|
+
def list_scheduled(ctx: Context, account_id: int | None) -> None:
|
|
31
|
+
"""List scheduled payments."""
|
|
32
|
+
client = ctx.get_client()
|
|
33
|
+
data = client.list_scheduled_payments(account_id=account_id)
|
|
34
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@scheduled.command("get")
|
|
38
|
+
@click.argument("id", type=int)
|
|
39
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
40
|
+
@pass_context
|
|
41
|
+
def get_scheduled(ctx: Context, id: int, account_id: int | None) -> None:
|
|
42
|
+
"""Get a single scheduled payment."""
|
|
43
|
+
client = ctx.get_client()
|
|
44
|
+
data = client.get_scheduled_payment(id, account_id=account_id)
|
|
45
|
+
echo_output(data, fmt=ctx.output_format)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@scheduled.command("create")
|
|
49
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
50
|
+
@click.option("--amount", required=True, help="Amount per payment (e.g., 25.00).")
|
|
51
|
+
@click.option("--currency", default="EUR", help="Currency code (default: EUR).")
|
|
52
|
+
@click.option("--recipient-iban", required=True, help="Recipient IBAN.")
|
|
53
|
+
@click.option("--recipient-name", required=True, help="Recipient name.")
|
|
54
|
+
@click.option("--description", required=True, help="Payment description.")
|
|
55
|
+
@click.option("--schedule-date", "start", required=True, type=click.DateTime(DATETIME_FORMATS), help="First payment (local time, e.g. 2026-11-01 or '2026-11-01 09:00').")
|
|
56
|
+
@click.option("--end-date", "end", type=click.DateTime(DATETIME_FORMATS), default=None, help="Stop after this date (optional).")
|
|
57
|
+
@click.option("--frequency", "unit", type=click.Choice(UNITS, case_sensitive=False), default="ONCE", help="How often to pay (default: ONCE).")
|
|
58
|
+
@click.option("--every", type=click.IntRange(min=1), default=1, help="Repeat every N periods, e.g. --every 2 --frequency WEEKLY.")
|
|
59
|
+
@yes_option
|
|
60
|
+
@pass_context
|
|
61
|
+
def create_scheduled(
|
|
62
|
+
ctx: Context,
|
|
63
|
+
account_id: int | None,
|
|
64
|
+
amount: str,
|
|
65
|
+
currency: str,
|
|
66
|
+
recipient_iban: str,
|
|
67
|
+
recipient_name: str,
|
|
68
|
+
description: str,
|
|
69
|
+
start: datetime,
|
|
70
|
+
end: datetime | None,
|
|
71
|
+
unit: str,
|
|
72
|
+
every: int,
|
|
73
|
+
yes: bool,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Schedule a (recurring) payment."""
|
|
76
|
+
client = ctx.get_client(write=True)
|
|
77
|
+
source = f"account {account_id}" if account_id else "the primary account"
|
|
78
|
+
ctx.confirm(
|
|
79
|
+
f"Schedule {amount} {currency} to {recipient_name} ({recipient_iban}) from {source}, "
|
|
80
|
+
f"starting {start:%Y-%m-%d %H:%M}, {unit.upper()}?",
|
|
81
|
+
yes,
|
|
82
|
+
)
|
|
83
|
+
schedule_id = client.create_scheduled_payment(
|
|
84
|
+
amount=amount,
|
|
85
|
+
currency=currency,
|
|
86
|
+
recipient_iban=recipient_iban,
|
|
87
|
+
recipient_name=recipient_name,
|
|
88
|
+
description=description,
|
|
89
|
+
time_start=_to_api_time(start),
|
|
90
|
+
time_end=_to_api_time(end) if end else None,
|
|
91
|
+
recurrence_unit=unit.upper(),
|
|
92
|
+
recurrence_size=every,
|
|
93
|
+
account_id=account_id,
|
|
94
|
+
)
|
|
95
|
+
if not ctx.quiet:
|
|
96
|
+
click.echo(f"Scheduled payment created (ID: {schedule_id}).")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@scheduled.command("delete")
|
|
100
|
+
@click.argument("id", type=int)
|
|
101
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
102
|
+
@pass_context
|
|
103
|
+
def cancel_scheduled(ctx: Context, id: int, account_id: int | None) -> None:
|
|
104
|
+
"""Cancel a scheduled payment."""
|
|
105
|
+
client = ctx.get_client(write=True)
|
|
106
|
+
client.cancel_scheduled_payment(id, account_id=account_id)
|
|
107
|
+
if not ctx.quiet:
|
|
108
|
+
click.echo(f"Scheduled payment {id} cancelled.")
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Account statement export commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from bunq_cli.main import Context, pass_context
|
|
11
|
+
from bunq_cli.output import echo_output
|
|
12
|
+
|
|
13
|
+
LIST_COLUMNS = ["id", "created", "date_start", "date_end", "format", "status"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def statements() -> None:
|
|
18
|
+
"""Export account statements."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@statements.command("list")
|
|
22
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
23
|
+
@pass_context
|
|
24
|
+
def list_statements(ctx: Context, account_id: int | None) -> None:
|
|
25
|
+
"""List statement exports."""
|
|
26
|
+
client = ctx.get_client()
|
|
27
|
+
data = client.list_statements(account_id=account_id)
|
|
28
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@statements.command("create")
|
|
32
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
33
|
+
@click.option("--from", "date_start", required=True, type=click.DateTime(["%Y-%m-%d"]), help="Start date (YYYY-MM-DD).")
|
|
34
|
+
@click.option("--to", "date_end", required=True, type=click.DateTime(["%Y-%m-%d"]), help="End date (YYYY-MM-DD).")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--format",
|
|
37
|
+
"statement_format",
|
|
38
|
+
type=click.Choice(["CSV", "PDF", "MT940"], case_sensitive=False),
|
|
39
|
+
default="CSV",
|
|
40
|
+
help="Statement format (default: CSV).",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--regional-format",
|
|
44
|
+
type=click.Choice(["EUROPEAN", "UK_US"], case_sensitive=False),
|
|
45
|
+
default="EUROPEAN",
|
|
46
|
+
help="CSV separator style: EUROPEAN (;) or UK_US (,). Only used for CSV.",
|
|
47
|
+
)
|
|
48
|
+
@pass_context
|
|
49
|
+
def create_statement(
|
|
50
|
+
ctx: Context,
|
|
51
|
+
account_id: int | None,
|
|
52
|
+
date_start: datetime,
|
|
53
|
+
date_end: datetime,
|
|
54
|
+
statement_format: str,
|
|
55
|
+
regional_format: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Request a statement export. Download it with 'statements download'."""
|
|
58
|
+
statement_format = statement_format.upper()
|
|
59
|
+
client = ctx.get_client()
|
|
60
|
+
statement_id = client.create_statement(
|
|
61
|
+
statement_format=statement_format,
|
|
62
|
+
date_start=date_start.strftime("%Y-%m-%d"),
|
|
63
|
+
date_end=date_end.strftime("%Y-%m-%d"),
|
|
64
|
+
regional_format=regional_format.upper() if statement_format == "CSV" else None,
|
|
65
|
+
account_id=account_id,
|
|
66
|
+
)
|
|
67
|
+
if not ctx.quiet:
|
|
68
|
+
click.echo(f"Statement export created (ID: {statement_id}).")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@statements.command("download")
|
|
72
|
+
@click.argument("id", type=int)
|
|
73
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
74
|
+
@click.option("-o", "--output-file", type=click.Path(path_type=Path, dir_okay=False), required=True, help="File to write the statement to.")
|
|
75
|
+
@pass_context
|
|
76
|
+
def download_statement(ctx: Context, id: int, account_id: int | None, output_file: Path) -> None:
|
|
77
|
+
"""Download a statement export."""
|
|
78
|
+
client = ctx.get_client()
|
|
79
|
+
if ctx.all_profiles:
|
|
80
|
+
if "{profile}" not in str(output_file):
|
|
81
|
+
raise click.UsageError("With --all-profiles, put {profile} in the output file name.")
|
|
82
|
+
output_file = Path(str(output_file).replace("{profile}", ctx.profile_name))
|
|
83
|
+
output_file.write_bytes(client.download_statement(id, account_id=account_id))
|
|
84
|
+
if not ctx.quiet:
|
|
85
|
+
click.echo(f"Statement saved to {output_file}.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@statements.command("delete")
|
|
89
|
+
@click.argument("id", type=int)
|
|
90
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
91
|
+
@pass_context
|
|
92
|
+
def delete_statement(ctx: Context, id: int, account_id: int | None) -> None:
|
|
93
|
+
"""Delete a statement export."""
|
|
94
|
+
client = ctx.get_client(write=True)
|
|
95
|
+
client.delete_statement(id, account_id=account_id)
|
|
96
|
+
if not ctx.quiet:
|
|
97
|
+
click.echo(f"Statement {id} deleted.")
|