Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion spond/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@


class AuthenticationError(Exception):
"""Error raised on Spond authentication failure."""
"""Raised when login to the Spond API fails.

Typical causes:

- Incorrect username/password.
- 2FA enabled on the account (the library does not currently support
Spond's TOTP flow — see #205).
- The account has hit Spond's login rate limit (`outOfLoginAttempts`).
- The Spond login API has changed shape and the response no longer
contains an `accessToken`.

The exception message includes any of the response's whitelisted
diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so
most error cases are self-explanatory. Other response fields — such
as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally
dropped from the message to avoid leaking them into application logs.
"""

pass
65 changes: 64 additions & 1 deletion spond/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
"""Shared base class for Spond API clients.

`_SpondBase` is the abstract parent of both `spond.spond.Spond` (consumer API)
and `spond.club.SpondClub` (Spond Club finance API). It owns the credentials,
the underlying aiohttp `ClientSession`, the access token, and the lazy login
flow used by the `require_authentication` decorator.

Not intended to be instantiated directly — use a subclass.
"""

from abc import ABC
from collections.abc import Callable

import aiohttp

from spond import AuthenticationError

# Fields from a login response that are safe to surface in an
# `AuthenticationError` message. Anything outside this set (notably 2FA
# challenge tokens and `phoneNumber`) is dropped to avoid leaking
# sensitive data into application logs.
_SAFE_LOGIN_ERROR_FIELDS = ("error", "errorKey", "errorCode", "message")


class _SpondBase(ABC):
"""Abstract base for Spond API clients.

Subclasses provide the API base URL via the third constructor argument
and inherit lazy authentication, the `auth_headers` property, the
`require_authentication` decorator, and the `login()` flow.
"""

def __init__(self, username: str, password: str, api_url: str) -> None:
"""Initialise credentials and open the aiohttp session.

Parameters
----------
username : str
Spond account email address.
password : str
Spond account password.
api_url : str
Base URL for the API family this client targets (consumer or
club). Must end with a trailing slash so relative paths can be
concatenated.
"""
self.username = username
self.password = password
self.api_url = api_url
Expand Down Expand Up @@ -58,9 +94,36 @@ async def login(self) -> None:

@staticmethod
def _extract_access_token(login_result: dict) -> str:
"""Pull the access-token string out of a `/auth2/login` response.

The response shape is
`{"accessToken": {"token": "<JWT>", "expiration": "..."}, ...}`.
This helper validates that shape and returns the bearer string used
for subsequent API calls.

Parameters
----------
login_result : dict
Parsed JSON body from the login endpoint.

Returns
-------
str
The bearer-token string.

Raises
------
AuthenticationError
The response is malformed or doesn't carry a usable token (e.g.
wrong credentials, account locked, 2FA required).
"""
access = login_result.get("accessToken")
if isinstance(access, dict):
token = access.get("token")
if isinstance(token, str) and token:
return token
raise AuthenticationError(f"Login failed. Response received: {login_result}")
safe = {
k: login_result[k] for k in _SAFE_LOGIN_ERROR_FIELDS if k in login_result
}
diagnostic = safe or "(no recognised diagnostic fields in response)"
raise AuthenticationError(f"Login failed. {diagnostic}")
Comment thread
Olen marked this conversation as resolved.
83 changes: 72 additions & 11 deletions spond/club.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
"""Client for the Spond Club finance API.

Spond Club is the paid administration tier sold to clubs/teams alongside the
free consumer app. It exposes a separate API (`api.spond.com/club/v1/`) for
finance-flavoured data such as transactions/payments. Use the `SpondClub`
class for this API and `spond.spond.Spond` for everything else.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar
Expand All @@ -9,37 +17,90 @@


class SpondClub(_SpondBase):
"""Async client for the Spond Club finance API.

Authentication is shared with the consumer API — the same email/password
credentials work, but the user must belong to at least one Spond Club
organisation and the `club_id` passed to each method must be one they
have access to. The `club_id` here is distinct from the consumer-API
`groupId`.

Example
-------
```python
import asyncio
from spond import club

async def main():
sc = club.SpondClub(username="me@example.invalid", password="secret")
txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50)
for t in txs:
print(t["paidAt"], t["paymentName"], t["paidByName"])
await sc.clientsession.close()

asyncio.run(main())
```
"""

_API_BASE_URL: ClassVar = "https://api.spond.com/club/v1/"

def __init__(self, username: str, password: str) -> None:
"""Construct a Spond Club client.

Parameters
----------
username : str
Spond account email. Same credentials as the consumer API; the
account must have access to at least one Spond Club organisation
for the API calls to return data.
password : str
Spond account password.
"""
super().__init__(username, password, self._API_BASE_URL)
self.transactions: list[JSONDict] | None = None

@_SpondBase.require_authentication
async def get_transactions(
self, club_id: str, skip: int | None = None, max_items: int = 100
) -> list[JSONDict]:
"""
Retrieves a list of transactions/payments for a specified club.
"""Retrieve transactions/payments for a Spond Club.

Spond's transactions endpoint returns at most 25 records per request,
so this method paginates internally (via recursion on `skip`) until
either `max_items` is reached or the server returns an empty page.

**Caching caveat**: results accumulate on `self.transactions` and
the cache is **not** keyed by `club_id` — calling this method again
with a different `club_id` on the same instance will append that
club's transactions to the same list, mixing the two. If you query
multiple clubs from one client, reset the cache between calls
(`sc.transactions = None`) or use a fresh `SpondClub` instance per
club.

Comment thread
Olen marked this conversation as resolved.
Each transaction dict typically includes at least `id`, `paidAt`,
`paymentName`, and `paidByName`. See `examples/transactions.py` for
a usage example.

Parameters
----------
club_id : str
Identifier for the club. Note that this is different from the Group ID used
in the core API.
Identifier for the club. Note that this is **different** from the
`groupId` used elsewhere in the Spond API — find it in the URL
of the Spond Club web UI.
skip : int, optional
This endpoint only returns 25 transactions at a time (page scrolling).
Therefore, we need to increment this `skip` param to grab the next
25 etc. Defaults to None. It's better to keep `skip` at None
and specify `max_items` instead. This param is only here for the
recursion implementation.
Pagination cursor (number of records to skip). Normally left as
`None`; the method increments it itself on recursive calls. Only
override if you know what you're doing.
max_items : int, optional
The maximum number of transactions to retrieve. Defaults to 100.
Stop fetching once at least this many transactions are
accumulated. Defaults to 100. The final list may be slightly
longer than `max_items` since the server returns pages of 25.

Returns
-------
list[JSONDict]
A list of transactions, each represented as a dictionary.
All transactions accumulated so far (across recursive page
fetches). Empty list if the club has no transactions.
"""
if self.transactions is None:
self.transactions = []
Expand Down
Loading