feat: connect Tiny through its v3 API with OAuth
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images and notify Portainer (push) Has been skipped

Tiny v3 replaces the v2 token adapter. An operator connects Tiny once from
the Kanban; the callback is authorised by a single-use state, because Tiny's
cross-site redirect does not carry the SameSite=Strict operator cookie.
Tokens are kept in provider_tokens, the refresh token rotates under a row
lock, and the worker keeps the connection alive while order creation is off.

Orders find or create the customer's contact by CNPJ, then POST /pedidos
with product ids from TINY_PRODUCT_TEXTIL_FOLHA, _TEXTIL_AVULSA, _UV_FOLHA
and _UV_AVULSA and numeroOrdemCompra DTF-<number>; a retry searches the
customer's recent orders for that number first. The product settings avoid a
_FILE suffix, which the secrets loader reads as a secret file path.

Production passes the application credentials through but keeps
TINY_ADAPTER fake: Tiny has no sandbox, so creating real orders waits for a
supervised test. compose.providers.yaml gives the local API and worker an
internet route for provider testing; the default local stack still has none.

Verified with the full CI integration sequence locally, including the new
tiny_oauth_test against the real database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 12:46:09 -03:00
parent c18b9e5b87
commit e3d5558198
17 changed files with 613 additions and 131 deletions

View File

@@ -22,8 +22,10 @@ def require_runtime():
raise RuntimeError(f'{name} must use the currently supported fake adapter')
tiny = os.environ.get('TINY_ADAPTER')
if tiny == 'tiny':
if not os.environ.get('TINY_TOKEN'):
raise RuntimeError('TINY_TOKEN is required for the Tiny adapter')
from .tiny import required_settings
for name in required_settings():
if not os.environ.get(name):
raise RuntimeError(f'{name} is required for the Tiny adapter')
elif tiny != 'fake':
raise RuntimeError('TINY must use the fake or tiny adapter')
# Mercado Pago is selectable only with its credentials present; it has not

View File

@@ -7,6 +7,7 @@ from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from psycopg.types.json import Jsonb
from ..core import db
@@ -15,6 +16,7 @@ from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, o
from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price
from ..printjobs import queue as queue_print_files
from .. import tiny
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -94,7 +96,7 @@ def board(user=Depends(operator)):
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
ORDER BY received_at LIMIT 100''').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': orders, 'payment_issues': refused,
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(),
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
@@ -207,6 +209,34 @@ def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
return {'ok': True}
def tiny_status():
if not tiny.configured():
return {'configured': False}
return {'configured': True, 'orders_enabled': os.environ.get('TINY_ADAPTER') == 'tiny',
**tiny.TinyAuth().status()}
@router.post('/api/operator/tiny/connect')
def tiny_connect(user=Depends(operator)):
"""Start the one-time authorisation of this system in the client's Tiny."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
single-use state an operator created is what authorises it."""
if not tiny.configured():
raise HTTPException(503, 'Tiny application is not configured')
try:
who = tiny.TinyAuth().complete(code, state)
except tiny.TinyError:
audit('tiny_connect_failed')
return RedirectResponse('/?tiny=failed', status_code=303)
audit('tiny_connected', operator=who)
return RedirectResponse('/?tiny=connected', status_code=303)
@router.get('/api/operator/orders/{uid}/history')
def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:

View File

@@ -99,6 +99,20 @@ CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(provider, provider_payment_id)
);
-- OAuth connections to providers (Tiny). One row per provider; the refresh
-- token rotates on use, so it lives here, never in configuration.
CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens (
provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL,
access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz,
connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Single-use states for an operator-started OAuth connection. They protect the
-- callback, which arrives cross-site without the operator's cookie.
CREATE TABLE IF NOT EXISTS dtf_local.oauth_states (
state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL,
expires_at timestamptz NOT NULL
);
-- The print file generated from each paid item's approved layout. One row per
-- item: the worker claims it, renders, and records either the file or why the
-- item has to be prepared by hand. Regenerating replaces the row's result.

View File

@@ -1,104 +1,264 @@
"""Tiny/Olist (API 2.0): create the sales order once a payment is approved.
"""Tiny/Olist ERP (API v3): create the sales order once a payment is approved.
Written from the public API documentation and exercised only against a fake
HTTP transport. Endpoints, product codes, tags and rate limits still have to
be confirmed against the client's account before this is a real integration.
HTTP transport. Tiny has no sandbox: the first real test creates a real order
in the client's ERP, so test with a marked order and cancel it afterwards.
Idempotency: the outbox may deliver the same event more than once (a timeout
after Tiny accepted it, a worker restart). Every order is created with
numero_pedido_ecommerce = "DTF-<order number>", and Tiny is searched for that
number first, so a second delivery finds the first order instead of creating
another one.
Authentication is OAuth2 on Tiny's Keycloak. The client creates an
"Aplicativo" in Tiny (Configurações > Geral > Aplicativos), which yields a
client ID and secret and registers our callback URL. An operator then clicks
"Conectar Tiny" on the Kanban once; the tokens are kept in the database and
the refresh token is rotated on every refresh, under a row lock so two
workers never spend the same one.
Tiny answers HTTP 200 for most failures and reports them in retorno.status,
so the body decides success. Anything unexpected raises, and the outbox
retries with backoff; nothing is marked delivered unless Tiny confirmed it.
Orders are created by contact and product id: the customer's contact is found
by CNPJ or created, and each product mode maps to a product that must already
exist in Tiny (PRODUCT_SETTINGS).
Idempotency: the outbox may deliver the same event more than once. Every order
carries numeroOrdemCompra = "DTF-<order number>", and before creating one the
customer's recent orders are searched for that number, so a second delivery
finds the first order instead of creating another.
"""
import json
import os
import secrets
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from urllib.parse import urlencode
import httpx
API = 'https://api.tiny.com.br/api2'
API = 'https://api.tiny.com.br/public-api/v3'
AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect'
# Not TINY_PRODUCT_<MODE>: app/core/secrets.py reads any variable ending in
# _FILE as a path to a secret file, and one product mode is called "file".
PRODUCT_SETTINGS = {'file': 'TINY_PRODUCT_TEXTIL_FOLHA', 'avulsa': 'TINY_PRODUCT_TEXTIL_AVULSA',
'uvfile': 'TINY_PRODUCT_UV_FOLHA', 'uv': 'TINY_PRODUCT_UV_AVULSA'}
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
'uvfile': 'DTF UV 28,5 cm · folha montada',
'uv': 'DTF UV 28,5 cm · artes avulsas'}
# How far back to look for an order a previous delivery may already have made.
SEARCH_DAYS = 7
STATE_MINUTES = 10
# Brazil has had no daylight saving since 2019; the order date is the local day.
BRASILIA = timezone(timedelta(hours=-3))
def local_today():
return datetime.now(BRASILIA).date()
class TinyError(Exception):
pass
def ecommerce_number(number):
class TinyNotConnected(TinyError):
"""No authorised connection yet: an operator must click "Conectar Tiny"."""
def purchase_order(number):
return f'DTF-{number}'
def configured():
"""Whether the OAuth application is configured (orders may still be fake)."""
return all(os.environ.get(name) for name in ('TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'))
def required_settings():
return ['TINY_CLIENT_ID', 'TINY_CLIENT_SECRET', 'TINY_REDIRECT_URI'] + list(PRODUCT_SETTINGS.values())
# OAuth -------------------------------------------------------------------
class TinyAuth:
"""The OAuth application and the stored connection."""
def __init__(self, connect=None, transport=None, clock=time.time):
self.client_id = os.environ.get('TINY_CLIENT_ID', '')
self.client_secret = os.environ.get('TINY_CLIENT_SECRET', '')
self.redirect_uri = os.environ.get('TINY_REDIRECT_URI', '')
if connect is None:
from .core.db import connect
self.connect = connect
self.http = httpx.Client(timeout=20, transport=transport)
self.clock = clock
def authorize_url(self, operator):
"""Start a connection. The state is single-use, short-lived, and only an
authenticated operator can create one, which is what protects the
callback: Tiny's redirect back is cross-site, so the operator's
SameSite=Strict cookie does not travel with it."""
state = secrets.token_urlsafe(32)
with self.connect() as c:
c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_at<now()")
c.execute('''INSERT INTO dtf_local.oauth_states(state,provider,operator,expires_at)
VALUES(%s,'tiny',%s,now()+%s)''', (state, operator, timedelta(minutes=STATE_MINUTES)))
return f'{AUTH}/auth?' + urlencode({'response_type': 'code', 'client_id': self.client_id,
'redirect_uri': self.redirect_uri, 'scope': 'openid',
'state': state})
def complete(self, code, state):
"""Exchange the authorisation code; returns the operator who started it."""
with self.connect() as c:
row = c.execute('''DELETE FROM dtf_local.oauth_states WHERE state=%s AND provider='tiny'
AND expires_at>now() RETURNING operator''', (state,)).fetchone()
if not row:
raise TinyError('Unknown or expired authorisation state')
tokens = self._token({'grant_type': 'authorization_code', 'code': code,
'redirect_uri': self.redirect_uri})
self._store(c, tokens, row['operator'])
return row['operator']
def status(self):
with self.connect() as c:
row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at
FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone()
if not row:
return {'connected': False}
expired = row['refresh_expires_at'] and row['refresh_expires_at'] <= datetime.now(timezone.utc)
return {'connected': not expired, 'connected_by': row['connected_by'],
'connected_at': row['connected_at'], 'expires_at': row['refresh_expires_at']}
def access_token(self):
"""A valid access token, refreshing (and rotating) under a row lock."""
with self.connect() as c:
row = c.execute('''SELECT * FROM dtf_local.provider_tokens WHERE provider='tiny'
FOR UPDATE''').fetchone()
if not row:
raise TinyNotConnected('Tiny is not connected; use "Conectar Tiny" on the Kanban')
now = datetime.now(timezone.utc)
if row['access_expires_at'] > now + timedelta(seconds=60):
return row['access_token']
if row['refresh_expires_at'] and row['refresh_expires_at'] <= now:
raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban')
tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']})
self._store(c, tokens, row['connected_by'], refreshed=True)
return tokens['access_token']
def _token(self, form):
response = self.http.post(f'{AUTH}/token', data={**form, 'client_id': self.client_id,
'client_secret': self.client_secret})
if response.status_code == 400 and form['grant_type'] == 'refresh_token':
raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban')
response.raise_for_status()
tokens = response.json()
if not tokens.get('access_token') or not tokens.get('refresh_token'):
raise TinyError('Tiny token response is missing tokens')
return tokens
def _store(self, c, tokens, operator, refreshed=False):
now = datetime.now(timezone.utc)
access_expires = now + timedelta(seconds=int(tokens.get('expires_in', 300)))
refresh_in = tokens.get('refresh_expires_in')
refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None
c.execute('''INSERT INTO dtf_local.provider_tokens(provider,access_token,refresh_token,
access_expires_at,refresh_expires_at,connected_by,connected_at,updated_at)
VALUES('tiny',%s,%s,%s,%s,%s,now(),now())
ON CONFLICT(provider) DO UPDATE SET access_token=EXCLUDED.access_token,
refresh_token=EXCLUDED.refresh_token, access_expires_at=EXCLUDED.access_expires_at,
refresh_expires_at=EXCLUDED.refresh_expires_at, updated_at=now(),
connected_by=CASE WHEN %s THEN dtf_local.provider_tokens.connected_by ELSE EXCLUDED.connected_by END,
connected_at=CASE WHEN %s THEN dtf_local.provider_tokens.connected_at ELSE now() END''',
(tokens['access_token'], tokens['refresh_token'], access_expires, refresh_expires,
operator, refreshed, refreshed))
# Orders ------------------------------------------------------------------
def money(cents):
return f'{Decimal(cents) / 100:.2f}'
return float(Decimal(cents) / 100)
def order_payload(payload):
"""The Tiny 'pedido' for a paid order's approved snapshot."""
order = payload['order']
def contact_payload(order):
customer = order['customer']
destination = order.get('destination')
client = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'],
'fone': customer['zap'], 'email': customer['mail']}
contact = {'nome': (destination or {}).get('recipient') or customer['mail'],
'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'],
'celular': customer['zap'], 'situacao': 'A'}
if destination:
client.update(endereco=destination['street'], numero=destination['number'],
complemento=destination.get('complement', ''), bairro=destination['district'],
cep=destination['postal_code'], cidade=destination['city'], uf=destination['state'])
contact['endereco'] = address(destination)
return contact
def address(destination):
return {'endereco': destination['street'], 'numero': destination['number'],
'complemento': destination.get('complement', ''), 'bairro': destination['district'],
'municipio': destination['city'], 'cep': destination['postal_code'],
'uf': destination['state'], 'pais': 'Brasil'}
def order_payload(payload, contact_id, today=None):
"""The v3 'pedido' for a paid order's approved snapshot."""
order = payload['order']
items = []
for item in order['items']:
code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '')
entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}",
'unidade': 'M', 'quantidade': item['billed_metres'],
'valor_unitario': money(item['unit_cents'])}
if code:
entry['codigo'] = code
items.append({'item': entry})
setting = PRODUCT_SETTINGS[item['mode']]
product = os.environ.get(setting, '')
if not product.isdigit():
raise TinyError(f'{setting} must be the Tiny product id')
items.append({'produto': {'id': int(product)},
'quantidade': float(Decimal(item['billed_metres'])),
'valorUnitario': money(item['unit_cents']),
'infoAdicional': PRODUCTS[item['mode']] + f" · nota {item['grade']}"})
freight = order['freight']
pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']),
'cliente': client, 'itens': items,
'valor_frete': money(freight['total_cents']),
'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"}
if freight.get('service') == 'pickup':
# What dispatch already receives for pickups today (see web/site-config.js).
pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R')
tag = os.environ.get('TINY_TAG', '')
if tag:
pedido['marcadores'] = [{'marcador': {'descricao': tag}}]
return {'pedido': pedido}
pickup = freight.get('service') == 'pickup'
pedido = {'data': (today or local_today()).isoformat(),
'idContato': contact_id,
'numeroOrdemCompra': purchase_order(payload['number']),
'itens': items,
'valorFrete': money(freight['total_cents']),
'observacoes': f"Pedido DTF #{payload['number']}" + (' · retirada em Franca' if pickup else ''),
'observacoesInternas': f"Site DTF · pago · {payload['order_id']}"}
destination = order.get('destination')
if destination:
pedido['enderecoEntrega'] = {**address(destination), 'enderecoNro': destination['number'],
'nomeDestinatario': destination['recipient']}
del pedido['enderecoEntrega']['numero']
ecommerce = os.environ.get('TINY_ECOMMERCE_ID', '')
if ecommerce.isdigit():
pedido['ecommerce'] = {'id': int(ecommerce), 'numeroPedidoEcommerce': purchase_order(payload['number'])}
return pedido
class TinyOrders:
def __init__(self, token=None, transport=None):
self.token = token or os.environ.get('TINY_TOKEN', '')
if not self.token:
raise RuntimeError('Tiny needs TINY_TOKEN')
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]
if auth is None and missing:
raise RuntimeError('Tiny needs ' + ', '.join(missing))
self.auth = auth or TinyAuth()
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
self.today = today
def call(self, method, **params):
response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params})
response.raise_for_status()
body = response.json().get('retorno', {})
if body.get('status') != 'OK':
errors = body.get('erros') or body.get('registros') or []
# "No records" is how the search reports an empty result.
if str(body.get('codigo_erro')) == '20':
return {'pedidos': []}
raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}")
return body
def request(self, method, path, **kwargs):
headers = {'Authorization': f'Bearer {self.auth.access_token()}'}
response = self.http.request(method, path, headers=headers, **kwargs)
if response.status_code == 429:
raise TinyError('Tiny rate limit reached; the outbox will retry')
if response.status_code >= 400:
raise TinyError(f'{method} {path}: {response.status_code} {response.text[:300]}')
return response.json() if response.content else {}
def find(self, number):
found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number))
for entry in found.get('pedidos') or []:
pedido = entry.get('pedido', entry)
if str(pedido.get('numero_ecommerce')) == ecommerce_number(number):
return pedido
def contact(self, order):
cnpj = order['customer']['cnpj']
found = self.request('GET', '/contatos', params={'cpfCnpj': cnpj, 'limit': 5})
for entry in found.get('itens') or []:
if ''.join(ch for ch in str(entry.get('cpfCnpj') or '') if ch.isdigit()) == cnpj:
return entry['id']
return self.request('POST', '/contatos', json=contact_payload(order))['id']
def find(self, payload):
"""An order a previous delivery already created, or None."""
wanted = purchase_order(payload['number'])
since = ((self.today or local_today()) - timedelta(days=SEARCH_DAYS)).isoformat()
found = self.request('GET', '/pedidos', params={'cpfCnpj': payload['order']['customer']['cnpj'],
'dataInicial': since, 'limit': 100})
for entry in found.get('itens') or []:
detail = self.request('GET', f"/pedidos/{entry['id']}")
if detail.get('numeroOrdemCompra') == wanted:
return detail
return None
def deliver(self, event_key, payload):
@@ -106,13 +266,11 @@ class TinyOrders:
# Production progress is not written to Tiny; only the sale is.
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
'event': payload.get('event')}
existing = self.find(payload['number'])
existing = self.find(payload)
if existing:
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))}
created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False))
record = (created.get('registros') or [{}])[0].get('registro', {})
if record.get('status') != 'OK':
raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}")
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))}
contact_id = self.contact(payload['order'])
created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today))
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}
'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))}

View File

@@ -21,6 +21,7 @@ if os.environ.get('TINY_ADAPTER') == 'tiny':
adapters['tiny'] = TinyOrders()
last_tick = 0.0
last_cleanup = 0.0
last_tiny_keepalive = 0.0
storage = LocalS3Storage()
scan_thread = None
render_thread = None
@@ -52,6 +53,27 @@ def tick():
c.execute("UPDATE dtf_local.outbox SET attempts=attempts+1, last_error=%s, available_at=now() + %s * interval '1 second' WHERE id=%s", (str(exc),delay,job['id']))
last_tick = time.monotonic()
def tiny_keepalive():
"""Keep a connected Tiny authorised even while no orders are sent.
The refresh token expires unless it is used; access_token() refreshes (and
rotates) only when the access token is about to expire, so asking every few
minutes renews the connection roughly once per access-token lifetime.
"""
global last_tiny_keepalive
if time.monotonic()-last_tiny_keepalive < 600:
return
last_tiny_keepalive = time.monotonic()
from . import tiny
if not tiny.configured():
return
try:
tiny.TinyAuth().access_token()
except tiny.TinyNotConnected:
pass
except Exception:
logging.exception('Tiny connection refresh failed')
def loop():
global last_cleanup
while True:
@@ -60,6 +82,7 @@ def loop():
if time.monotonic()-last_cleanup > 60:
cleanup()
last_cleanup = time.monotonic()
tiny_keepalive()
except Exception:
logging.exception('Local worker tick failed')
time.sleep(1)