Metadata-Version: 2.5
Name: finansfatura
Version: 0.2.0
Summary: Python client for the Finansfatura e-invoice (e-Fatura / e-Arşiv) API
Project-URL: Homepage, https://github.com/finansfatura/ff-python
Project-URL: Issues, https://github.com/finansfatura/ff-python/issues
Author: Finansfatura
License: MIT
License-File: LICENSE
Keywords: e-invoice,earsiv,efatura,finansfatura,gib,invoicing
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.8
Requires-Dist: requests>=2.25
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# finansfatura

Python client for the [Finansfatura](https://finansfatura.com) API — turn orders
into sales, issue e-Fatura / e-Arşiv documents, and follow their status.
Full API reference: [apidocs.finansfatura.com](https://apidocs.finansfatura.com).

## Install

```bash
pip install finansfatura
```

## The flow

An integration is two steps, in this order:

```
1. create_order()   POST /v1/integrations/orders     → transaction_id
2. issue_invoice()  POST /v1/invoicing/invoices/     → invoice_id
   order_status()   GET  /v1/integrations/…/status   → invoice_number
```

Step 2 is **optional** — if you only push sales, the taxpayer invoices them from
the panel, one by one or in bulk. That is the smoothest start for most
integrations.

Step 1 is not optional. It is what puts the order in the turnover report, the
current account and the stock, and what keeps the order alive when invoicing
fails (no credits, bad VKN, GİB down).

## Quickstart

```python
import os
from finansfatura import FinansfaturaClient, build_earsiv_payload

ff = FinansfaturaClient(api_key=os.environ["FINANSFATURA_API_KEY"])  # ff_live_...

# 1 — the sale. Prices KDV-INCLUSIVE, vat_rate as a percentage.
sale = ff.create_order({
    "provider": "ECOMSOFT",              # your brand; unknown values show as FINANSFATURA
    "external_id": "ORD-2026-00184",     # your stable order id
    "order_number": "184",
    "payment_status": "PAID",
    "currency": "TRY",
    "total_price": 120.0,
    "buyer": {"title": "Ahmet Yılmaz", "tckn": "11111111111",
              "email": "ahmet@example.com", "address": "Kadıköy / İstanbul"},
    "lines": [{"sku": "SKU-1042", "title": "Kablosuz Kulaklık",
               "quantity": 1, "unit_price": 120.0, "total_price": 120.0,
               "vat_rate": 20}],
})

# 2 — the invoice. Prices KDV-EXCLUSIVE, vat_rate as a ratio.
payload = build_earsiv_payload(
    recipient={"vkn_tckn": "11111111111", "title": "Ahmet Yılmaz"},
    lines=[{"title": "Kablosuz Kulaklık", "product_code": "SKU-1042",
            "qty": 1, "unit_price": 100.0, "vat_rate": 0.20}],
    transaction_header_id=sale["transaction_id"],
)
result = ff.issue_invoice(payload, idempotency_key="ORD-2026-00184")
print(result["invoice_id"], result["status"])   # -> ... QUEUED
```

> **The two endpoints disagree about VAT on purpose.** The order body carries
> KDV-**inclusive** prices with a percentage (`120`, `20`); the invoice body
> carries KDV-**exclusive** prices with a ratio (`100`, `0.20`). This is the most
> common integration bug — the builders keep the invoice side honest, the order
> side is yours.

`transaction_header_id` links the invoice to the sale. Without it the invoice
exists but the sale does not know about it: no turnover, no current account, no
stock movement.

### Sandbox

```python
from finansfatura import SANDBOX_BASE_URL

ff = FinansfaturaClient(api_key="ff_test_...", base_url=SANDBOX_BASE_URL)
```

Sandbox and production are entirely separate systems — keys, OAuth clients and
data never cross over.

### Typed inputs (optional)

Prefer autocomplete and early "missing field" errors over raw dicts? Pass
`Party` / `Line` dataclasses instead — same result, no extra dependency:

```python
from finansfatura import Party, Line, build_earsiv_payload

payload = build_earsiv_payload(
    recipient=Party(vkn_tckn="11111111111", title="Ahmet Yılmaz", email="ahmet@example.com"),
    lines=[Line(title="Kablosuz Kulaklık", qty=1, unit_price=100.0, vat_rate=0.20)],
)
```

Dicts and dataclasses are interchangeable everywhere; use whichever you like.

`build_earsiv_payload` computes totals from the lines (Decimal, no float drift)
and applies the API's exact field casing for you: the outer layer is snake_case
(`document_type`, `canonical`) but everything inside `canonical` is PascalCase
(`Recipient`, `Lines`, `Totals`, `VKNorTCKN`). A snake_case key inside `canonical`
is silently ignored by the server, so let the builder handle it.

## Idempotency

`idempotency_key` is **required** on `issue_invoice` and can be any unique string
(use your order id). Retrying with the same key never double-issues and never
charges credits twice. Likewise, resending the same `external_id` to
`create_order` never duplicates the sale — you get `200` with
`already_imported: true` instead of `201`. Both are what make retries safe.

## Following the status

The invoice number is **not** in the issue response — the provider assigns it a
moment later. Read it from the bulk status endpoint:

```python
res = ff.order_status("ecomsoft", ["ORD-2026-00184", "ORD-2026-00185"])
for s in res["statuses"]:
    print(s["external_id"], s["invoice_status"], s.get("invoice_number"))
```

Up to 50 ids per call. Orders we never received are simply absent from the
response, so match on `external_id` — don't trust the order. Statuses are
`NOT_INVOICED`, `QUEUED`, `ISSUED`, `ACCEPTED`, `REJECTED`, `CANCELLED`; the last
three are final.

Check once in the first minute after issuing, then every few minutes for the
records that are not final yet. Polling faster does not make GİB answer sooner.

## Reading & lifecycle

```python
ff.get_invoice(invoice_id)
ff.list_invoices(page=1, page_size=20)
pdf_bytes = ff.download(invoice_id, "pdf")  # or "html" / "xml"
ff.cancel(invoice_id)                       # e-Arşiv outright; e-Fatura is a process
```

## Errors

Failed calls raise a typed exception carrying `.status`, `.body` and `.retryable`:

| Exception | HTTP | Meaning | Retry |
|-----------|------|---------|-------|
| `ValidationError` | 400, 422 | bad body — `.body["errors"]` names the fields | ❌ |
| `AuthError` | 401 | key/token missing, invalid, revoked or expired | ❌ |
| `InsufficientCredits` | 402 | not enough credits (kontör) | ❌ |
| `ScopeError` | 403 | missing scope, or endpoint closed to API keys | ❌ |
| `OnboardingRequired` | 412 | the taxpayer's e-invoice setup is unfinished | ❌ |
| `RateLimitError` | 429 | too many requests | ✅ |
| `ProviderError` | 5xx | transient upstream / provider unreachable | ✅ |
| `FinansfaturaError` | other | base class | — |

```python
from finansfatura import FinansfaturaError, OnboardingRequired

try:
    ff.issue_invoice(payload, idempotency_key=f"order-{order.id}")
except OnboardingRequired:
    # The sale is safe. Tell the merchant to finish setup in the panel; the
    # pending sales can be invoiced later.
    ...
except FinansfaturaError as e:
    if e.retryable:
        schedule_retry(order.id)   # 1s, 2s, 4s, 8s …
    else:
        log.error("issue failed [%s]: %s", e.status, e.body)
```

## e-Fatura vs e-Arşiv

Send `EARSIV` and let the server correct it. When `RecipientAlias` is left empty
(the default), we ask GİB about the recipient's VKN: registered taxpayers are
upgraded to `EFATURA` with the mailbox alias resolved, everyone else stays
`EARSIV`. You don't need to run the lookup yourself.

```python
from finansfatura import build_efatura_payload

payload = build_efatura_payload(
    recipient={"vkn_tckn": "1234567890", "title": "Kurum A.Ş."},
    lines=[...],
    # recipient_alias="urn:mail:defaultpk@example.com",  # only if you already know it
)
```

Other document types (`EIRSALIYE`, `ESMM`, `EMM`, `EADISYON`) go through
`build_payload(document_type, ...)`.

## OAuth 2.0

API keys bind to one company. If your product serves many taxpayers, register an
OAuth client (partner@finansfatura.com) and drop the copy-paste step:

```python
from finansfatura import FinansfaturaClient, OAuth, generate_pkce

oauth = OAuth(client_id=..., client_secret=..., redirect_uri="https://app.example.com/ff/callback")

# 1 — send the taxpayer to the consent screen
verifier, challenge = generate_pkce()        # keep `verifier` in the session
session["ff_verifier"] = verifier
redirect(oauth.authorize_url(code_challenge=challenge, state=csrf_token))

# 2 — the callback comes back with ?code=…
token = oauth.exchange_code(request.args["code"], code_verifier=session["ff_verifier"])
store(token["access_token"], token["refresh_token"], token["expires_in"])

# 3 — use it
ff = FinansfaturaClient(access_token=token["access_token"])
```

```python
token = oauth.refresh(stored_refresh_token)   # store the NEW refresh token
oauth.revoke(stored_refresh_token)            # end the connection
```

- `redirect_uri` must match a registered address **exactly**; partial matches are
  rejected.
- Every refresh invalidates the previous refresh token. Persist the newest one or
  the connection dies.
- Scopes: `invoice:read` (status/reads) and `invoice:write` (sales, issuing,
  cancelling). Ask only for what you use.
- Token and revoke URLs keep their **trailing slash** — the client handles it.

## Notes

- Seller identity (`Issuer`) is filled server-side from your company profile —
  don't send it. Make sure the profile VKN is set, or issuing returns 503.
- Keep the API key server-side and encrypted; it acts on the taxpayer's company.
  Never ship it to a browser or mobile app.
- `base_url` is the API host only (`https://api.finansfatura.com`) — paths are
  built by the client. Since 0.2.0 it no longer includes `/v1/invoicing`.

## Development

```bash
pip install -e ".[dev]"
python -m pytest        # or: python -m unittest discover tests
```

## License

MIT
