"""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. 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. 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. 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-", 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. Customer notices: the client already sends WhatsApp messages from Tiny's order situação (Tiny webhook -> their middleware -> n8n). With TINY_STATUS_UPDATES on, a paid order is set to "Aprovada" and a finished pickup order to "Pronto para envio", so those notices reach Site customers from the same number and templates; the system's own WhatsApp sender stays off. Pickup orders carry the client's "retirar pessoalmente" forma de envio. """ 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/public-api/v3' AUTH = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect' # Not TINY_PRODUCT_: 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 # A "ready" event can come after corrections; it normally knows the Tiny id. READY_SEARCH_DAYS = 45 # Tiny v3 order situações (PUT /pedidos/{id}/situacao). ABERTA, APROVADA, PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE = 0, 3, 7, 5, 6, 2, 9 SITUACOES = {0: 'Aberta', 3: 'Aprovada', 4: 'Preparando envio', 1: 'Faturada', 7: 'Pronto para envio', 5: 'Enviada', 6: 'Entregue', 2: 'Cancelada', 8: 'Dados incompletos', 9: 'Não entregue'} # The client's customer notices (Tiny webhook -> middleware -> n8n -> WhatsApp) # react to these situações. Off until go-live: while n8n still sends the # designer message for DTFIMP products on "Aprovado", setting it would reach # Site customers. STATUS_UPDATES_SETTING = 'TINY_STATUS_UPDATES' # The id of the client's custom "retirar pessoalmente" forma de envio (v2 code # X), which the pickup notice is keyed on. Find it with the console tool. PICKUP_SETTING = 'TINY_FORMA_ENVIO_RETIRADA' STATE_MINUTES = 10 # Renewal runs about every four hours against a one-day refresh token, so # less than this left means renewals have been failing for hours. EXPIRY_WARNING = timedelta(hours=12) # 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 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()) def status_updates(): return os.environ.get(STATUS_UPDATES_SETTING, '').lower() == 'true' # 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, offline=True): """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. offline_access asks for a grant that is not bound to a login session, so the connection lasts as long as it keeps being renewed. Tiny's documented refresh token otherwise lasts one day.""" state = secrets.token_urlsafe(32) with self.connect() as c: c.execute("DELETE FROM dtf_local.oauth_states WHERE expires_atnow() RETURNING operator''', (state,)).fetchone() if not row: raise TinyError('Unknown or expired authorisation state') return row['operator'] def complete(self, code, state): """Exchange the authorisation code; returns the operator who started it.""" with self.connect() as c: operator = self._claim(c, state) tokens = self._token({'grant_type': 'authorization_code', 'code': code, 'redirect_uri': self.redirect_uri}) self._store(c, tokens, operator) return operator def without_offline(self, state): """Tiny refused the offline_access scope for this application: spend the operator's state and start again with a session-bound grant.""" with self.connect() as c: operator = self._claim(c, state) return self.authorize_url(operator, offline=False) def status(self): with self.connect() as c: row = c.execute('''SELECT connected_by,connected_at,refresh_expires_at,updated_at,offline, refresh_failed_at,refused_at FROM dtf_local.provider_tokens WHERE provider='tiny' ''').fetchone() if not row: return {'connected': False} now = datetime.now(timezone.utc) expires = row['refresh_expires_at'] expired = bool(expires and expires <= now) if row['refused_at']: problem = 'refused' elif expired: problem = 'expired' elif row['refresh_failed_at']: problem = 'renewal-failing' elif expires and expires - now < EXPIRY_WARNING: problem = 'expiring' else: problem = None return {'connected': not expired and not row['refused_at'], 'problem': problem, 'connected_by': row['connected_by'], 'connected_at': row['connected_at'], 'renewed_at': row['updated_at'], 'expires_at': expires, 'offline': row['offline'], 'failed_at': row['refresh_failed_at']} def access_token(self): """A valid access token, refreshing (and rotating) under a row lock. A failed renewal is recorded after the lock is released, so the Kanban can show it. A refused refresh token is never tried again: only a new connection helps, and retrying it would only repeat the refusal.""" 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['refused_at']: raise TinyNotConnected('Tiny refused the stored refresh token; connect again on the Kanban') if row['refresh_expires_at'] and row['refresh_expires_at'] <= now: raise TinyNotConnected('The Tiny connection expired; connect again on the Kanban') try: tokens = self._token({'grant_type': 'refresh_token', 'refresh_token': row['refresh_token']}) except (TinyError, httpx.HTTPError, ValueError) as exc: failure = exc else: self._store(c, tokens, row['connected_by'], refreshed=True) return tokens['access_token'] # Only against the token that failed: another worker may have renewed since. with self.connect() as c: c.execute('''UPDATE dtf_local.provider_tokens SET refresh_failed_at=now(), refresh_error=%s, refused_at=CASE WHEN %s THEN now() ELSE refused_at END WHERE provider='tiny' AND refresh_token=%s''', (str(failure)[:300] or type(failure).__name__, isinstance(failure, TinyNotConnected), row['refresh_token'])) raise failure 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))) # An offline grant reports refresh_expires_in 0: no fixed end. refresh_in = tokens.get('refresh_expires_in') refresh_expires = now + timedelta(seconds=int(refresh_in)) if refresh_in else None offline = 'offline_access' in str(tokens.get('scope') or '').split() 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,offline) VALUES('tiny',%s,%s,%s,%s,%s,now(),now(),%s) 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(), offline=EXCLUDED.offline, refresh_failed_at=NULL, refresh_error=NULL, refused_at=NULL, 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, offline, refreshed, refreshed)) # Orders ------------------------------------------------------------------ def money(cents): return float(Decimal(cents) / 100) def contact_payload(order): customer = order['customer'] destination = order.get('destination') contact = {'nome': (destination or {}).get('recipient') or customer['mail'], 'tipoPessoa': 'J', 'cpfCnpj': customer['cnpj'], 'email': customer['mail'], 'celular': customer['zap'], 'situacao': 'A'} if destination: 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']: 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'] 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']}"} pickup_method = os.environ.get(PICKUP_SETTING, '') if pickup and pickup_method.isdigit(): pedido['transportador'] = {'formaEnvio': {'id': int(pickup_method)}} 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 def configured_product(orders, mode): """The Tiny product a mode's setting points at, or a reason it cannot be used.""" setting = PRODUCT_SETTINGS[mode] value = os.environ.get(setting, '') if not value.isdigit(): return None, f'{setting} sem id' found = orders.request('GET', f'/produtos/{value}') if found.get('situacao') != 'A': return found, f"produto {value} não está ativo no Tiny ({found.get('situacao')})" return found, 'ok' def configured_pickup(orders): """The forma de envio the pickup setting points at, or why it cannot be used.""" value = os.environ.get(PICKUP_SETTING, '') if not value.isdigit(): return None, f'{PICKUP_SETTING} sem id' return orders.request('GET', f'/formas-envio/{value}'), 'ok' def check(auth=None, transport=None, orders=None): """Read-only proof that the connection and permissions work: one order and one contact listed, each configured product found active and the pickup forma de envio found, nothing created. Raises TinyNotConnected when there is no usable connection; otherwise reports each read separately.""" orders = orders or TinyOrders(auth=auth or TinyAuth(), transport=transport) def listed(path): orders.request('GET', path, params={'limit': 1}) return 'ok' reads = [('pedidos', lambda: listed('/pedidos')), ('contatos', lambda: listed('/contatos'))] reads += [(PRODUCTS[mode], lambda mode=mode: configured_product(orders, mode)[1]) for mode in PRODUCT_SETTINGS] reads.append(('forma de envio de retirada', lambda: configured_pickup(orders)[1])) results = {} for name, read in reads: try: results[name] = read() except TinyNotConnected: raise except TinyError as exc: results[name] = str(exc)[:200] except httpx.HTTPError: results[name] = 'sem resposta do Tiny' return results class TinyOrders: 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 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 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, days=SEARCH_DAYS): """An order a previous delivery already created, or None.""" wanted = purchase_order(payload['number']) since = ((self.today or local_today()) - timedelta(days=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 set_situacao(self, tiny_id, situacao): self.request('PUT', f'/pedidos/{tiny_id}/situacao', json={'situacao': situacao}) def deliver(self, event_key, payload): event = payload.get('event') if event == 'payment_approved': return self.sale(event_key, payload) if event == 'ready' and status_updates(): return self.ready(event_key, payload) # Other production events have no Tiny situação and are not written. return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable', 'event': event} def sale(self, event_key, payload): """Create the order once; with status updates on, approve it once. An order is created "Aberta" and then set to "Aprovada", so a retry after a failure between the two finds it still open and finishes the job, and an order someone has already moved on is left alone.""" existing = self.find(payload) if existing: receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created', 'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numeroPedido'))} tiny_id, situacao = existing.get('id'), existing.get('situacao') else: contact_id = self.contact(payload['order']) created = self.request('POST', '/pedidos', json=order_payload(payload, contact_id, self.today)) receipt = {'provider': 'tiny', 'event_key': event_key, 'status': 'created', 'tiny_id': str(created.get('id')), 'tiny_number': str(created.get('numeroPedido'))} tiny_id, situacao = created.get('id'), ABERTA if status_updates() and situacao == ABERTA: self.set_situacao(tiny_id, APROVADA) receipt['situacao'] = SITUACOES[APROVADA] return receipt def ready(self, event_key, payload): """A pickup order became ready: "Pronto para envio", which the client's notices turn into the pickup message. Shipped orders get "Enviada" once freight exists (1.2).""" base = {'provider': 'tiny', 'event_key': event_key} order = payload.get('order') or {} if (order.get('freight') or {}).get('service') != 'pickup': return {**base, 'status': 'not-applicable', 'event': 'ready'} tiny_id = payload.get('tiny_id') detail = self.request('GET', f'/pedidos/{tiny_id}') if tiny_id else self.find(payload, READY_SEARCH_DAYS) if not detail: raise TinyError(f"{purchase_order(payload['number'])} is not in Tiny yet; the outbox will retry") base.update(tiny_id=str(detail.get('id')), tiny_number=str(detail.get('numeroPedido'))) situacao = detail.get('situacao') if situacao in (PRONTO_ENVIO, ENVIADA, ENTREGUE, CANCELADA, NAO_ENTREGUE): return {**base, 'status': 'status-unchanged', 'situacao': SITUACOES.get(situacao, str(situacao))} self.set_situacao(detail['id'], PRONTO_ENVIO) return {**base, 'status': 'status-updated', 'situacao': SITUACOES[PRONTO_ENVIO]}