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
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Accounts commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "description", "type", "currency", "balance", "iban", "status"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def accounts() -> None:
|
|
15
|
+
"""Manage monetary accounts."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@accounts.command("list")
|
|
19
|
+
@click.option("--include-inactive", is_flag=True, help="Include closed accounts.")
|
|
20
|
+
@pass_context
|
|
21
|
+
def list_accounts(ctx: Context, include_inactive: bool) -> None:
|
|
22
|
+
"""List all monetary accounts."""
|
|
23
|
+
client = ctx.get_client()
|
|
24
|
+
data = client.list_accounts()
|
|
25
|
+
if not include_inactive:
|
|
26
|
+
data = [a for a in data if a.get("status") == "ACTIVE"]
|
|
27
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@accounts.command("get")
|
|
31
|
+
@click.argument("id", type=int)
|
|
32
|
+
@pass_context
|
|
33
|
+
def get_account(ctx: Context, id: int) -> None:
|
|
34
|
+
"""Get a single monetary account by ID."""
|
|
35
|
+
client = ctx.get_client()
|
|
36
|
+
data = client.get_account(id)
|
|
37
|
+
echo_output(data, fmt=ctx.output_format)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""bunq.me payment link commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "created", "status", "amount", "currency", "description", "url"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def bunqme() -> None:
|
|
15
|
+
"""Manage bunq.me payment links."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@bunqme.command("list")
|
|
19
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
20
|
+
@pass_context
|
|
21
|
+
def list_tabs(ctx: Context, account_id: int | None) -> None:
|
|
22
|
+
"""List bunq.me payment links."""
|
|
23
|
+
client = ctx.get_client()
|
|
24
|
+
data = client.list_bunqme_tabs(account_id=account_id)
|
|
25
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@bunqme.command("get")
|
|
29
|
+
@click.argument("id", type=int)
|
|
30
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
31
|
+
@pass_context
|
|
32
|
+
def get_tab(ctx: Context, id: int, account_id: int | None) -> None:
|
|
33
|
+
"""Get a single bunq.me payment link."""
|
|
34
|
+
client = ctx.get_client()
|
|
35
|
+
data = client.get_bunqme_tab(id, account_id=account_id)
|
|
36
|
+
echo_output(data, fmt=ctx.output_format)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@bunqme.command("create")
|
|
40
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
41
|
+
@click.option("--amount", required=True, help="Amount to request (e.g., 15.00).")
|
|
42
|
+
@click.option("--currency", default="EUR", help="Currency code (default: EUR).")
|
|
43
|
+
@click.option("--description", required=True, help="Description shown to the payer.")
|
|
44
|
+
@click.option("--redirect-url", default=None, help="URL to send the payer to after paying.")
|
|
45
|
+
@pass_context
|
|
46
|
+
def create_tab(
|
|
47
|
+
ctx: Context,
|
|
48
|
+
account_id: int | None,
|
|
49
|
+
amount: str,
|
|
50
|
+
currency: str,
|
|
51
|
+
description: str,
|
|
52
|
+
redirect_url: str | None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Create a bunq.me payment link."""
|
|
55
|
+
client = ctx.get_client(write=True)
|
|
56
|
+
data = client.create_bunqme_tab(
|
|
57
|
+
amount=amount,
|
|
58
|
+
currency=currency,
|
|
59
|
+
description=description,
|
|
60
|
+
redirect_url=redirect_url,
|
|
61
|
+
account_id=account_id,
|
|
62
|
+
)
|
|
63
|
+
echo_output(data, fmt=ctx.output_format)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@bunqme.command("cancel")
|
|
67
|
+
@click.argument("id", type=int)
|
|
68
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
69
|
+
@pass_context
|
|
70
|
+
def cancel_tab(ctx: Context, id: int, account_id: int | None) -> None:
|
|
71
|
+
"""Cancel a bunq.me payment link."""
|
|
72
|
+
client = ctx.get_client(write=True)
|
|
73
|
+
client.cancel_bunqme_tab(id, account_id=account_id)
|
|
74
|
+
if not ctx.quiet:
|
|
75
|
+
click.echo(f"bunq.me link {id} cancelled.")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Callbacks (notification filter) 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", "category", "notification_target"]
|
|
11
|
+
|
|
12
|
+
CATEGORIES = [
|
|
13
|
+
"MUTATION",
|
|
14
|
+
"PAYMENT",
|
|
15
|
+
"INSTANT_PAYMENT",
|
|
16
|
+
"PAYMENT_DELAYED",
|
|
17
|
+
"PAYMENT_SUSPENDED",
|
|
18
|
+
"PAYMENT_UPDATED",
|
|
19
|
+
"DRAFT_PAYMENT",
|
|
20
|
+
"REQUEST",
|
|
21
|
+
"SCHEDULE_RESULT",
|
|
22
|
+
"SCHEDULE_STATUS",
|
|
23
|
+
"IDEAL",
|
|
24
|
+
"SOFORT",
|
|
25
|
+
"CARD_TRANSACTION_SUCCESSFUL",
|
|
26
|
+
"CARD_TRANSACTION_FAILED",
|
|
27
|
+
"CARD_TRANSACTION_UPDATED",
|
|
28
|
+
"CARD_TRANSACTION_REFUND",
|
|
29
|
+
"CARD_TRANSACTION_TOKENIZED",
|
|
30
|
+
"CARD_DYNAMIC_CURRENCY_CONVERSION_WARNING",
|
|
31
|
+
"CARD_EXPIRY",
|
|
32
|
+
"CARD_REPLACEMENT",
|
|
33
|
+
"CARD_COUNTRY_PERMISSION",
|
|
34
|
+
"CARD_TOKENIZATION",
|
|
35
|
+
"MASTERCARD_IDENTITY_CHECK",
|
|
36
|
+
"MONETARY_ACCOUNT_BALANCE",
|
|
37
|
+
"MONETARY_ACCOUNT_JOINT",
|
|
38
|
+
"CO_OWNER_INVITE",
|
|
39
|
+
"APPROVAL_REQUEST_USER",
|
|
40
|
+
"APPROVAL_REQUEST_MONETARY_ACCOUNT",
|
|
41
|
+
"WHITELIST",
|
|
42
|
+
"WHITELIST_RESULT",
|
|
43
|
+
"TAB_RESULT",
|
|
44
|
+
"BUNQME_TAB",
|
|
45
|
+
"BUNQME_TAB_RESULT_INQUIRY",
|
|
46
|
+
"BUNQME_TAB_RESULT_RESPONSE",
|
|
47
|
+
"OAUTH",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group()
|
|
52
|
+
def callbacks() -> None:
|
|
53
|
+
"""Manage notification filters (webhooks)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@callbacks.command("list")
|
|
57
|
+
@pass_context
|
|
58
|
+
def list_callbacks(ctx: Context) -> None:
|
|
59
|
+
"""List all notification filters."""
|
|
60
|
+
client = ctx.get_client()
|
|
61
|
+
data = client.list_callbacks()
|
|
62
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@callbacks.command("create")
|
|
66
|
+
@click.option("--url", required=True, help="Callback URL.")
|
|
67
|
+
@click.option(
|
|
68
|
+
"--category",
|
|
69
|
+
"categories",
|
|
70
|
+
multiple=True,
|
|
71
|
+
type=click.Choice(CATEGORIES, case_sensitive=False),
|
|
72
|
+
required=True,
|
|
73
|
+
help="Event category (can be specified multiple times).",
|
|
74
|
+
)
|
|
75
|
+
@pass_context
|
|
76
|
+
def create_callback(ctx: Context, url: str, categories: tuple[str, ...]) -> None:
|
|
77
|
+
"""Create notification filters for one or more event categories."""
|
|
78
|
+
if not url.lower().startswith("https://"):
|
|
79
|
+
raise click.BadParameter("bunq requires an HTTPS callback URL.", param_hint="--url")
|
|
80
|
+
client = ctx.get_client(write=True)
|
|
81
|
+
data = client.create_callback(url, [c.upper() for c in categories])
|
|
82
|
+
if not ctx.quiet:
|
|
83
|
+
click.echo(f"Created {len(categories)} notification filter(s).")
|
|
84
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@callbacks.command("delete")
|
|
88
|
+
@yes_option
|
|
89
|
+
@pass_context
|
|
90
|
+
def delete_callbacks(ctx: Context, yes: bool) -> None:
|
|
91
|
+
"""Remove all notification filters."""
|
|
92
|
+
client = ctx.get_client(write=True)
|
|
93
|
+
ctx.confirm("Remove ALL notification filters?", yes)
|
|
94
|
+
client.delete_all_callbacks()
|
|
95
|
+
if not ctx.quiet:
|
|
96
|
+
click.echo("All notification filters removed.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@callbacks.command("failures")
|
|
100
|
+
@pass_context
|
|
101
|
+
def list_failures(ctx: Context) -> None:
|
|
102
|
+
"""List callbacks that failed after all retries."""
|
|
103
|
+
client = ctx.get_client()
|
|
104
|
+
data = client.list_callback_failures()
|
|
105
|
+
echo_output(
|
|
106
|
+
data,
|
|
107
|
+
fmt=ctx.output_format,
|
|
108
|
+
columns=["id", "created", "category", "event_type", "object_id", "response_code"],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@callbacks.command("retry")
|
|
113
|
+
@click.argument("ids", type=int, nargs=-1, required=True)
|
|
114
|
+
@pass_context
|
|
115
|
+
def retry_failures(ctx: Context, ids: tuple[int, ...]) -> None:
|
|
116
|
+
"""Retry failed callbacks by failure ID (max 100)."""
|
|
117
|
+
if len(ids) > 100:
|
|
118
|
+
raise click.BadParameter("At most 100 IDs can be retried at once.", param_hint="IDS")
|
|
119
|
+
client = ctx.get_client(write=True)
|
|
120
|
+
client.retry_callback_failures(list(ids))
|
|
121
|
+
if not ctx.quiet:
|
|
122
|
+
click.echo(f"Retrying {len(ids)} callback(s).")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cards commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "type", "product_type", "status", "second_line", "expiry_date", "card_limit"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def cards() -> None:
|
|
15
|
+
"""Manage cards."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cards.command("list")
|
|
19
|
+
@pass_context
|
|
20
|
+
def list_cards(ctx: Context) -> None:
|
|
21
|
+
"""List all cards."""
|
|
22
|
+
client = ctx.get_client()
|
|
23
|
+
data = client.list_cards()
|
|
24
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cards.command("get")
|
|
28
|
+
@click.argument("id", type=int)
|
|
29
|
+
@pass_context
|
|
30
|
+
def get_card(ctx: Context, id: int) -> None:
|
|
31
|
+
"""Get a single card by ID."""
|
|
32
|
+
client = ctx.get_client()
|
|
33
|
+
data = client.get_card(id)
|
|
34
|
+
echo_output(data, fmt=ctx.output_format)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@cards.command("freeze")
|
|
38
|
+
@click.argument("id", type=int)
|
|
39
|
+
@pass_context
|
|
40
|
+
def freeze_card(ctx: Context, id: int) -> None:
|
|
41
|
+
"""Temporarily freeze a card."""
|
|
42
|
+
client = ctx.get_client(write=True)
|
|
43
|
+
client.update_card(id, status="DEACTIVATED")
|
|
44
|
+
if not ctx.quiet:
|
|
45
|
+
click.echo(f"Card {id} frozen.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@cards.command("unfreeze")
|
|
49
|
+
@click.argument("id", type=int)
|
|
50
|
+
@pass_context
|
|
51
|
+
def unfreeze_card(ctx: Context, id: int) -> None:
|
|
52
|
+
"""Unfreeze a frozen card."""
|
|
53
|
+
client = ctx.get_client(write=True)
|
|
54
|
+
client.update_card(id, status="ACTIVE")
|
|
55
|
+
if not ctx.quiet:
|
|
56
|
+
click.echo(f"Card {id} unfrozen.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cards.command("update")
|
|
60
|
+
@click.argument("id", type=int)
|
|
61
|
+
@click.option("--daily-limit", type=float, default=None, help="Daily spending limit (e.g., 500.00).")
|
|
62
|
+
@click.option("--atm-limit", type=float, default=None, help="Daily ATM withdrawal limit (e.g., 250.00).")
|
|
63
|
+
@click.option("--country-permission", "countries", multiple=True, help="Country code the card may be used in (repeatable, replaces the current list).")
|
|
64
|
+
@click.option("--change-pin", is_flag=True, help="Prompt for a new PIN code.")
|
|
65
|
+
@pass_context
|
|
66
|
+
def update_card(
|
|
67
|
+
ctx: Context,
|
|
68
|
+
id: int,
|
|
69
|
+
daily_limit: float | None,
|
|
70
|
+
atm_limit: float | None,
|
|
71
|
+
countries: tuple[str, ...],
|
|
72
|
+
change_pin: bool,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Update card settings."""
|
|
75
|
+
from bunq.sdk.model.generated.object_ import AmountObject, CardCountryPermissionObject
|
|
76
|
+
|
|
77
|
+
kwargs = {}
|
|
78
|
+
if daily_limit is not None:
|
|
79
|
+
kwargs["card_limit"] = AmountObject(f"{daily_limit:.2f}", "EUR")
|
|
80
|
+
if atm_limit is not None:
|
|
81
|
+
kwargs["card_limit_atm"] = AmountObject(f"{atm_limit:.2f}", "EUR")
|
|
82
|
+
if countries:
|
|
83
|
+
kwargs["country_permission"] = [
|
|
84
|
+
CardCountryPermissionObject(country=c.upper()) for c in countries
|
|
85
|
+
]
|
|
86
|
+
if change_pin:
|
|
87
|
+
kwargs["pin_code"] = click.prompt("New PIN", hide_input=True, confirmation_prompt=True)
|
|
88
|
+
|
|
89
|
+
if not kwargs:
|
|
90
|
+
raise click.ClickException(
|
|
91
|
+
"No updates specified. Use --daily-limit, --atm-limit, --country-permission or --change-pin."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
client = ctx.get_client(write=True)
|
|
95
|
+
data = client.update_card(id, **kwargs)
|
|
96
|
+
echo_output(data, fmt=ctx.output_format)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
TRANSACTION_COLUMNS = ["id", "created", "amount", "currency", "merchant", "card_id", "decision", "status"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@cards.command("transactions")
|
|
103
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
104
|
+
@click.option("--count", type=int, default=25, help="Number of results.")
|
|
105
|
+
@click.option("--older-id", type=int, default=None, help="Cursor: fetch results older than this ID.")
|
|
106
|
+
@pass_context
|
|
107
|
+
def list_transactions(ctx: Context, account_id: int | None, count: int, older_id: int | None) -> None:
|
|
108
|
+
"""List card transactions (authorisations, declines, refunds)."""
|
|
109
|
+
client = ctx.get_client()
|
|
110
|
+
data = client.list_card_transactions(account_id=account_id, count=count, older_id=older_id)
|
|
111
|
+
echo_output(data, fmt=ctx.output_format, columns=TRANSACTION_COLUMNS)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cards.command("transaction")
|
|
115
|
+
@click.argument("id", type=int)
|
|
116
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
117
|
+
@pass_context
|
|
118
|
+
def get_transaction(ctx: Context, id: int, account_id: int | None) -> None:
|
|
119
|
+
"""Get a single card transaction."""
|
|
120
|
+
client = ctx.get_client()
|
|
121
|
+
data = client.get_card_transaction(id, account_id=account_id)
|
|
122
|
+
echo_output(data, fmt=ctx.output_format)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Draft payment commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "created", "status", "amount", "currency", "counterparty", "description"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group("draft-payments")
|
|
14
|
+
def drafts() -> None:
|
|
15
|
+
"""Manage draft payments (approved in the bunq app)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@drafts.command("list")
|
|
19
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
20
|
+
@pass_context
|
|
21
|
+
def list_drafts(ctx: Context, account_id: int | None) -> None:
|
|
22
|
+
"""List draft payments."""
|
|
23
|
+
client = ctx.get_client()
|
|
24
|
+
data = client.list_draft_payments(account_id=account_id)
|
|
25
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@drafts.command("get")
|
|
29
|
+
@click.argument("id", type=int)
|
|
30
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
31
|
+
@pass_context
|
|
32
|
+
def get_draft(ctx: Context, id: int, account_id: int | None) -> None:
|
|
33
|
+
"""Get a single draft payment."""
|
|
34
|
+
client = ctx.get_client()
|
|
35
|
+
data = client.get_draft_payment(id, account_id=account_id)
|
|
36
|
+
echo_output(data, fmt=ctx.output_format)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@drafts.command("create")
|
|
40
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
41
|
+
@click.option("--amount", required=True, help="Amount to send (e.g., 25.00).")
|
|
42
|
+
@click.option("--currency", default="EUR", help="Currency code (default: EUR).")
|
|
43
|
+
@click.option("--recipient-iban", required=True, help="Recipient IBAN.")
|
|
44
|
+
@click.option("--recipient-name", required=True, help="Recipient name.")
|
|
45
|
+
@click.option("--description", required=True, help="Payment description.")
|
|
46
|
+
@pass_context
|
|
47
|
+
def create_draft(
|
|
48
|
+
ctx: Context,
|
|
49
|
+
account_id: int | None,
|
|
50
|
+
amount: str,
|
|
51
|
+
currency: str,
|
|
52
|
+
recipient_iban: str,
|
|
53
|
+
recipient_name: str,
|
|
54
|
+
description: str,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Create a draft payment. It is only executed after approval in the bunq app."""
|
|
57
|
+
client = ctx.get_client(write=True)
|
|
58
|
+
draft_id = client.create_draft_payment(
|
|
59
|
+
amount=amount,
|
|
60
|
+
currency=currency,
|
|
61
|
+
recipient_iban=recipient_iban,
|
|
62
|
+
recipient_name=recipient_name,
|
|
63
|
+
description=description,
|
|
64
|
+
account_id=account_id,
|
|
65
|
+
)
|
|
66
|
+
if not ctx.quiet:
|
|
67
|
+
click.echo(f"Draft payment created (ID: {draft_id}). Approve it in the bunq app.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@drafts.command("reject")
|
|
71
|
+
@click.argument("id", type=int)
|
|
72
|
+
@click.option("--account", "account_id", type=int, default=None, help="Monetary account ID (default: primary).")
|
|
73
|
+
@pass_context
|
|
74
|
+
def reject_draft(ctx: Context, id: int, account_id: int | None) -> None:
|
|
75
|
+
"""Reject (withdraw) a pending draft payment."""
|
|
76
|
+
client = ctx.get_client(write=True)
|
|
77
|
+
client.reject_draft_payment(id, account_id=account_id)
|
|
78
|
+
if not ctx.quiet:
|
|
79
|
+
click.echo(f"Draft payment {id} rejected.")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Event (activity feed) commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bunq_cli.main import Context, pass_context
|
|
8
|
+
from bunq_cli.output import echo_output
|
|
9
|
+
|
|
10
|
+
LIST_COLUMNS = ["id", "created", "action", "object_type", "object_id", "account_id", "status"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def events() -> None:
|
|
15
|
+
"""View the account activity feed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@events.command("list")
|
|
19
|
+
@click.option("--account", "account_id", type=int, default=None, help="Only events for this monetary account.")
|
|
20
|
+
@click.option("--count", type=int, default=25, help="Number of results.")
|
|
21
|
+
@click.option("--older-id", type=int, default=None, help="Cursor: fetch results older than this ID.")
|
|
22
|
+
@pass_context
|
|
23
|
+
def list_events(ctx: Context, account_id: int | None, count: int, older_id: int | None) -> None:
|
|
24
|
+
"""List recent events."""
|
|
25
|
+
client = ctx.get_client()
|
|
26
|
+
data = client.list_events(account_id=account_id, count=count, older_id=older_id)
|
|
27
|
+
echo_output(data, fmt=ctx.output_format, columns=LIST_COLUMNS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@events.command("get")
|
|
31
|
+
@click.argument("id", type=int)
|
|
32
|
+
@pass_context
|
|
33
|
+
def get_event(ctx: Context, id: int) -> None:
|
|
34
|
+
"""Get a single event, including the object it refers to."""
|
|
35
|
+
client = ctx.get_client()
|
|
36
|
+
data = client.get_event(id)
|
|
37
|
+
echo_output(data, fmt=ctx.output_format)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Login and logout commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from bunq_cli.client import create_context, current_user_name, destroy_context
|
|
11
|
+
from bunq_cli.config import (
|
|
12
|
+
CONTEXTS_DIR,
|
|
13
|
+
load_config,
|
|
14
|
+
remove_profile,
|
|
15
|
+
resolve_profile,
|
|
16
|
+
save_config,
|
|
17
|
+
set_profile,
|
|
18
|
+
validate_profile_name,
|
|
19
|
+
)
|
|
20
|
+
from bunq_cli.main import Context, pass_context
|
|
21
|
+
from bunq_cli.oauth import oauth_login
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_ips(ctx: click.Context, param: click.Parameter, value: tuple[str, ...]) -> list[str]:
|
|
25
|
+
for ip in value:
|
|
26
|
+
if ip != "*":
|
|
27
|
+
try:
|
|
28
|
+
ipaddress.ip_address(ip)
|
|
29
|
+
except ValueError:
|
|
30
|
+
raise click.BadParameter(f"{ip!r} is not an IP address or '*'.") from None
|
|
31
|
+
return list(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_profile(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
|
|
35
|
+
return validate_profile_name(value) if value else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _contexts_dir() -> Path:
|
|
39
|
+
CONTEXTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
try:
|
|
41
|
+
CONTEXTS_DIR.chmod(0o700)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
return CONTEXTS_DIR
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.command()
|
|
48
|
+
@click.option("--profile", "profile_name", default=None, callback=_validate_profile, help="Profile name to store this login under (e.g. the client's name).")
|
|
49
|
+
@click.option("--token", default=None, help="API key (skip OAuth and use this token directly).")
|
|
50
|
+
@click.option("--sandbox", is_flag=True, help="Use the sandbox environment.")
|
|
51
|
+
@click.option(
|
|
52
|
+
"--allowed-ip",
|
|
53
|
+
"allowed_ips",
|
|
54
|
+
multiple=True,
|
|
55
|
+
default=["*"],
|
|
56
|
+
show_default=True,
|
|
57
|
+
callback=_validate_ips,
|
|
58
|
+
help="IP address the API key may be used from (repeatable). With '*', bunq assigns "
|
|
59
|
+
"the IP you log in from, or allows any IP for wildcard API keys.",
|
|
60
|
+
)
|
|
61
|
+
@click.option("--read-only/--read-write", default=None, help="Block commands that change data for this profile.")
|
|
62
|
+
@pass_context
|
|
63
|
+
def login(
|
|
64
|
+
ctx: Context,
|
|
65
|
+
profile_name: str | None,
|
|
66
|
+
token: str | None,
|
|
67
|
+
sandbox: bool,
|
|
68
|
+
allowed_ips: list[str],
|
|
69
|
+
read_only: bool | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Log in to a bunq account and store it as a profile.
|
|
72
|
+
|
|
73
|
+
Without --token, opens your browser for OAuth authorization.
|
|
74
|
+
With --token, uses the API key directly.
|
|
75
|
+
"""
|
|
76
|
+
sandbox = sandbox or ctx.sandbox
|
|
77
|
+
config = load_config(ctx.config_path)
|
|
78
|
+
profiles = config.get("profiles", {})
|
|
79
|
+
name = profile_name or ctx.profile
|
|
80
|
+
if name is None and profiles:
|
|
81
|
+
# Guessing could overwrite another client's login.
|
|
82
|
+
raise click.UsageError(
|
|
83
|
+
f"Pass --profile NAME (existing profiles: {', '.join(profiles)})."
|
|
84
|
+
)
|
|
85
|
+
name = validate_profile_name(name or "default")
|
|
86
|
+
|
|
87
|
+
if token is None:
|
|
88
|
+
token = oauth_login(sandbox=sandbox, config=config)
|
|
89
|
+
|
|
90
|
+
context_path = profiles.get(name, {}).get("context_path") or str(_contexts_dir() / f"{name}.conf")
|
|
91
|
+
create_context(
|
|
92
|
+
api_key=token,
|
|
93
|
+
context_path=Path(context_path).expanduser(),
|
|
94
|
+
sandbox=sandbox,
|
|
95
|
+
permitted_ips=allowed_ips,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
config = set_profile(
|
|
99
|
+
config,
|
|
100
|
+
name,
|
|
101
|
+
context_path=context_path,
|
|
102
|
+
sandbox=sandbox,
|
|
103
|
+
display_name=current_user_name(),
|
|
104
|
+
read_only=read_only,
|
|
105
|
+
)
|
|
106
|
+
save_config(config, ctx.config_path)
|
|
107
|
+
|
|
108
|
+
if not ctx.quiet:
|
|
109
|
+
env_name = "sandbox" if sandbox else "production"
|
|
110
|
+
who = config["profiles"][name].get("display_name") or "unknown user"
|
|
111
|
+
click.echo(f"Logged in to bunq ({env_name}) as {who}, profile '{name}'.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@click.command()
|
|
115
|
+
@click.option("--profile", "profile_name", default=None, help="Profile to log out (default: the selected profile).")
|
|
116
|
+
@click.option("--all", "all_profiles", is_flag=True, help="Log out of every profile.")
|
|
117
|
+
@pass_context
|
|
118
|
+
def logout(ctx: Context, profile_name: str | None, all_profiles: bool) -> None:
|
|
119
|
+
"""Close the session and remove a profile's stored credentials."""
|
|
120
|
+
config = load_config(ctx.config_path)
|
|
121
|
+
if all_profiles:
|
|
122
|
+
names = list(config.get("profiles", {}))
|
|
123
|
+
else:
|
|
124
|
+
names = [resolve_profile(config, profile_name or ctx.profile)[0]]
|
|
125
|
+
|
|
126
|
+
for name in names:
|
|
127
|
+
destroy_context(Path(config["profiles"][name]["context_path"]).expanduser())
|
|
128
|
+
config = remove_profile(config, name)
|
|
129
|
+
if not ctx.quiet:
|
|
130
|
+
click.echo(f"Logged out of profile '{name}'.")
|
|
131
|
+
save_config(config, ctx.config_path)
|