Metadata-Version: 2.5
Name: onemax
Version: 0.2.0
Summary: Python client for the OneMax partner API
Project-URL: Homepage, https://onemax.az
Project-URL: Repository, https://github.com/onemax-az/onemax-python
Project-URL: Changelog, https://github.com/onemax-az/onemax-python/blob/main/CHANGELOG.md
Author: OneMax
License-Expression: MIT
License-File: LICENSE
Keywords: api,azerbaijan,membership,onemax,partner
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# onemax

Python client for the [OneMax](https://onemax.az) partner API.

OneMax is a 1+1 membership club in Baku. Members pay for a plan and show a code at the counter to
use a partner's offer. This library lets a partner's own system do what the counter does: check
that someone is a member and record that they used the offer.

Sync and async, fully typed, one dependency.

```bash
pip install onemax
```

## Getting a key

1. OneMax switches API access on for your business.
2. The owner opens the partner panel, goes to API and creates a key.
3. The key is shown once. It belongs to one venue and acts there like a cashier.

Keep the key on your server. It must never reach a browser or a mobile app.

## Quick start: a member's code

A member opens their OneMax card and reads you the six digit code.

```python
from onemax import OneMaxClient

client = OneMaxClient("omx_live_...")

check = client.verify_code("482915")
if not check.usable:
    print("No discount:", check.reason)
else:
    order = place_order(discounted=True)
    client.confirm_usage(check.usage_id, reference=order.id)
```

`verify_code` only answers the question. Nothing counts against the member until you call
`confirm_usage`. If the order falls through, call `void_usage(check.usage_id)` instead.

Pass your own order id as `reference`. Confirming again with the same reference returns the same
usage, so a retried request is safe.

## Account linking

A member links their OneMax account to your app once. After that you check them without a code.
Linking is the OAuth 2.0 authorization code flow with PKCE, so the same steps work for a website
and a mobile app.

Register the addresses your app may return to in the partner panel, under API, Account linking.
Your client id is shown there.

**1. Send the member to the permission page.**

```python
from onemax import generate_pkce

pkce = generate_pkce()
session["onemax_verifier"] = pkce.verifier

url = client.authorize_url(
    client_id="omx_client_...",
    redirect_uri="https://example.az/onemax/return",
    code_challenge=pkce.challenge,
    state=session_id,
)
```

In a mobile app, open `url` in the system browser and use your app link as the `redirect_uri`,
for example `taksi://onemax/return`.

**2. The member returns to your address** with `code` and `state`, or with `error=access_denied`
if they declined. Check that `state` is the one you sent.

**3. Exchange the code on your server and store the token.**

```python
link = client.exchange_code(
    code,
    code_verifier=session["onemax_verifier"],
    redirect_uri="https://example.az/onemax/return",
)
save(user_id, link.link_token)
```

The code works once and for five minutes.

**4. Check the member whenever you need to.**

```python
status = client.link_status(link_token)
if not status.linked:
    forget(user_id)

check = client.verify_link(link_token)
if check.usable:
    client.confirm_usage(check.usage_id, reference=order.id)
```

A member can unlink at any time in their OneMax profile. From then on `link_status` answers
`linked=False` and `verify_link` answers `reason="not_linked"`. Neither raises.

You learn only whether the subscription is active. The member's name, email, phone and photo are
never shared.

## Test mode

Build and try your integration before a real member is involved. Create a test key in the partner
panel: it starts with `omx_test_` and works on the same address with the same methods. Nothing a
test key does touches real members, usages or statistics. Going live means swapping the key.

```python
client = OneMaxClient("omx_test_...")
client.mode
```

`client.mode` is `Mode.TEST` for a test key and `Mode.LIVE` otherwise. Every result also carries
`mode`, the mode the API answered in.

| Code | Test member | `reason` |
| --- | --- | --- |
| `000001` | Active member, never runs out | `ok` |
| `000002` | One use left today, then `limit_reached` | `ok` |
| `000003` | Reached the limit | `limit_reached` |
| `000004` | No subscription | `not_subscribed` |
| `000005` | Offer not available now | `offer_unavailable` |

Any other code returns `invalid_code`. The partner panel's test mode tab also shows a renewing QR
code for each test member, and lists your recent test requests.

For account linking, build the permission page address with the test client ID from the panel.
The test page asks for no sign-in: you pick a test member and allow or deny, and it returns to your
address as the live page does. Exchange the code with your test key.

## Async

`AsyncOneMaxClient` has the same methods, awaited:

```python
from onemax import AsyncOneMaxClient

async with AsyncOneMaxClient("omx_live_...") as client:
    check = await client.verify_code("482915")
```

## Results

`verify_code` and `verify_link` return a `Verification`:

| Field | Meaning |
| --- | --- |
| `active` | The member has a subscription that works today |
| `usable` | They may use the offer right now |
| `reason` | Why, as a `Reason` |
| `usage_id` | Set when `usable` is true. Confirm it or void it |
| `daily_limit` | How many times a day a member may use this venue |

| `Reason` | Meaning |
| --- | --- |
| `OK` | Go ahead |
| `NOT_SUBSCRIBED` | No subscription, or it has ended |
| `LIMIT_REACHED` | The member already used today's allowance at this venue |
| `OFFER_UNAVAILABLE` | The venue or the offer is not open right now |
| `INVALID_CODE` | The code is wrong, expired or already used |
| `NOT_LINKED` | The link token no longer works |

`Reason` and `UsageStatus` are string enums, so `check.reason == "ok"` works as well. A value this
version does not know is returned as a plain string.

If a venue has several offers open, pass `offer_id`. `client.context()` lists them.

## Errors

Everything the library raises is a `OneMaxError`.

| Class | When |
| --- | --- |
| `TransportError` | No usable answer: a connection failure, a timeout, or a body that is not JSON |
| `ApiError` | The API answered with an error. Has `status`, `code`, `message`, `details`, `request_id` |
| `AuthenticationError` | 401. The key is missing, wrong or switched off |
| `AccessDisabledError` | 403. API access is switched off for the partner |
| `NotFoundError` | 404. No such usage for this key |
| `ConflictError` | 409. For example `usage_limit_reached`, `already_applied`, `reference_used` |
| `InvalidRequestError` | 400 or 422. For example `offer_required`, `link_code_invalid` |
| `RateLimitError` | 429. Has `retry_after` in seconds |
| `ServerError` | 500 and above |

```python
from onemax import ConflictError, OneMaxError

try:
    client.confirm_usage(usage_id, reference=order.id)
except ConflictError as error:
    if error.code == "usage_limit_reached":
        charge_full_price(order)
    else:
        raise
except OneMaxError:
    retry_later(order)
```

`message` is in Azerbaijani and meant for your logs. Decide on `code`.

## Limits

- The member's daily limit applies exactly as it does at a counter.
- 120 requests a minute per key.
- The library never retries by itself. `confirm_usage` with a `reference` is safe to retry.

## Development

```bash
uv sync
uv run ruff format --check .
uv run ruff check .
uv run mypy src
uv run pytest
```

## Releasing

Set the version in `src/onemax/_version.py`, add a `## x.y.z` section to `CHANGELOG.md`, commit, then push a tag
`vx.y.z`. The `Publish` workflow checks that the tag equals the package version, runs the checks,
publishes to PyPI and creates the GitHub release from the changelog section. A tag that does not match the
version publishes nothing.

## License

MIT
