Metadata-Version: 2.5
Name: finansfatura
Version: 0.3.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, and neither is the buyer inside it. The sale 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). The
buyer is what the current account ("cari") is resolved from — an existing one is
matched on `tax_number` → `tckn` → `email` → `title`, and a new one created when
nothing matches — so `title` (or `contact_name`) is required: it is the name the
cari gets. Send `tckn` / `tax_number` when your channel has them and the matching
gets stronger, but they are not required.

Which makes the chain fixed: **cari → sale → document.** `transaction_header_id` is required
when you do reach step 2; the only exception is a refund (`invoice_type_code="IADE"`), which stays
unattached so the sale is not counted twice.

## 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 — the builder raises
without it, and so does the API. A document that hangs off no sale is invisible to
the turnover report, the current account and stock.

### 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`.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## Development

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

## License

MIT
