"""Tiny/Olist (API 2.0): 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. 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-", and Tiny is searched for that number first, so a second delivery finds the first order instead of creating another 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. """ import json import os from decimal import Decimal import httpx API = 'https://api.tiny.com.br/api2' 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'} class TinyError(Exception): pass def ecommerce_number(number): return f'DTF-{number}' def money(cents): return f'{Decimal(cents) / 100:.2f}' def order_payload(payload): """The Tiny 'pedido' for a paid order's approved snapshot.""" order = 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']} 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']) 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}) 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} 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') self.http = httpx.Client(base_url=API, transport=transport, timeout=30) 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 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 return None def deliver(self, event_key, payload): if payload.get('event') != 'payment_approved': # 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']) 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]}") return {'provider': 'tiny', 'event_key': event_key, 'status': 'created', 'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}