All checks were successful
Build and deploy / Validate source (push) Successful in 4s
Build and deploy / Integration suite on a real stack (push) Successful in 2m13s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images (push) Successful in 1m9s
A credit card payment with the test credentials was refused with 10113
("the payment method is excluded by a rule"). Every card was sent with
three_d_secure_mode, which only debit needs, and with the order's CNPJ as
payer instead of the cardholder's document from the card form. Debit
methods keep 3-D Secure, and the card form's document is the payer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
10 KiB
Python
246 lines
10 KiB
Python
import re
|
|
from decimal import Decimal
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
class StrictModel(BaseModel):
|
|
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
|
|
|
|
class Customer(StrictModel):
|
|
cnpj: str
|
|
zap: str
|
|
mail: str = Field(max_length=254)
|
|
|
|
@field_validator('cnpj')
|
|
@classmethod
|
|
def cnpj_valid(cls, value):
|
|
digits = re.sub(r'\D', '', value)
|
|
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
|
|
raise ValueError('Invalid CNPJ')
|
|
def check(base, weights):
|
|
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
|
|
return 0 if rem < 2 else 11-rem
|
|
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
|
|
raise ValueError('Invalid CNPJ')
|
|
return digits
|
|
|
|
@field_validator('zap')
|
|
@classmethod
|
|
def phone_valid(cls, value):
|
|
digits = re.sub(r'\D', '', value)
|
|
if len(digits) not in (10,11) or not digits.isascii():
|
|
raise ValueError('Invalid phone')
|
|
return digits
|
|
|
|
@field_validator('mail')
|
|
@classmethod
|
|
def email_valid(cls, value):
|
|
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
|
|
raise ValueError('Invalid email')
|
|
return value.strip()
|
|
|
|
class Freight(StrictModel):
|
|
service: Literal['pickup','mock-standard','jadlog'] = 'pickup'
|
|
postal_code: str = Field(default='', max_length=8)
|
|
|
|
class FreightEstimate(Freight):
|
|
"""The cart's estimate. The charged freight is quoted again by the server
|
|
from the approved items, never from these numbers."""
|
|
metres: Decimal | None = Field(default=None, gt=0, le=12000)
|
|
declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True)
|
|
|
|
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
|
|
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
|
|
|
class Destination(StrictModel):
|
|
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
|
|
recipient: str = Field(min_length=2, max_length=120)
|
|
street: str = Field(min_length=2, max_length=160)
|
|
number: str = Field(min_length=1, max_length=20)
|
|
complement: str = Field(default='', max_length=80)
|
|
district: str = Field(min_length=2, max_length=80)
|
|
city: str = Field(min_length=2, max_length=80)
|
|
state: UF
|
|
postal_code: str = Field(pattern=r'^[0-9]{8}$')
|
|
|
|
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
|
|
@classmethod
|
|
def trimmed(cls, value):
|
|
if isinstance(value, str):
|
|
value = ' '.join(value.split())
|
|
if any(ord(ch) < 32 for ch in value):
|
|
raise ValueError('Invalid characters')
|
|
return value
|
|
|
|
class UploadStart(StrictModel):
|
|
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
|
|
size: int = Field(gt=0, strict=True)
|
|
|
|
@field_validator('name')
|
|
@classmethod
|
|
def artwork_extension(cls, value):
|
|
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
|
|
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
|
|
raise ValueError('Unsupported artwork file extension')
|
|
return value
|
|
|
|
class ProductionSource(StrictModel):
|
|
upload_id: UUID
|
|
kind: Literal['sheet', 'artwork']
|
|
width_cm: Decimal = Field(gt=0, le=57)
|
|
length_cm: Decimal = Field(gt=0, le=6000)
|
|
copies: int = Field(ge=1, le=200, strict=True)
|
|
rotation_degrees: Literal[0, 90] = 0
|
|
mirrored: bool = False
|
|
measurement: Literal['file', 'customer']
|
|
|
|
class ProductionPlacement(StrictModel):
|
|
source_index: int = Field(ge=0, le=19, strict=True)
|
|
copy_index: int = Field(ge=0, le=199, strict=True)
|
|
x_cm: Decimal = Field(ge=0, le=57)
|
|
y_cm: Decimal = Field(ge=0, le=1200000)
|
|
width_cm: Decimal = Field(gt=0, le=57)
|
|
length_cm: Decimal = Field(gt=0, le=6000)
|
|
rotation_degrees: Literal[0, 90, 180, 270]
|
|
mirrored: bool
|
|
|
|
class ProductionSpec(StrictModel):
|
|
version: Literal[2]
|
|
film_width_cm: Decimal
|
|
height_cm: Decimal = Field(gt=0, le=1200000)
|
|
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
|
|
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
|
|
|
|
class Item(StrictModel):
|
|
mode: Literal['file','avulsa','uvfile','uv']
|
|
metres: Decimal = Field(gt=0, le=12000)
|
|
grade: int = Field(ge=0, le=100, strict=True)
|
|
uploads: list[UUID] = Field(min_length=1, max_length=20)
|
|
production: ProductionSpec
|
|
quality_status: Literal['ok', 'warning', 'unverified']
|
|
quality_acknowledged: bool
|
|
|
|
@model_validator(mode='after')
|
|
def production_matches_uploads(self):
|
|
if [source.upload_id for source in self.production.sources] != self.uploads:
|
|
raise ValueError('Production sources must match uploaded files in order')
|
|
is_sheet = self.mode in ('file', 'uvfile')
|
|
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
|
|
if self.production.film_width_cm != film_width:
|
|
raise ValueError('Production film width does not match the product')
|
|
for source in self.production.sources:
|
|
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
|
|
raise ValueError('Production source does not fit the selected product')
|
|
if is_sheet and (source.rotation_degrees or source.mirrored):
|
|
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
|
|
expected={(index,copy) for index,source in enumerate(self.production.sources)
|
|
for copy in range(source.copies)}
|
|
placed=set()
|
|
tolerance=Decimal('0.02')
|
|
for placement in self.production.placements:
|
|
key=(placement.source_index,placement.copy_index)
|
|
if key not in expected or key in placed:
|
|
raise ValueError('Production placement has a missing or duplicate source copy')
|
|
placed.add(key)
|
|
source=self.production.sources[placement.source_index]
|
|
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
|
|
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
|
|
raise ValueError('Production placement changes the source transform')
|
|
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
|
|
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
|
|
raise ValueError('Production placement changes the source size')
|
|
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
|
|
raise ValueError('Production placement is outside the film')
|
|
if is_sheet and (placement.x_cm or auto_rotation):
|
|
raise ValueError('Finished sheets must retain their original orientation')
|
|
if placed != expected:
|
|
raise ValueError('Production layout does not cover every source copy')
|
|
if self.quality_status == 'warning' and not self.quality_acknowledged:
|
|
raise ValueError('Resolution warning must be acknowledged')
|
|
return self
|
|
|
|
class QuoteRequest(StrictModel):
|
|
request_key: UUID
|
|
customer: Customer
|
|
items: list[Item] = Field(min_length=1, max_length=30)
|
|
freight: Freight
|
|
destination: Destination | None = None
|
|
|
|
@model_validator(mode='after')
|
|
def destination_matches_freight(self):
|
|
if self.freight.service == 'pickup':
|
|
if self.destination is not None:
|
|
raise ValueError('Pickup orders do not take a delivery address')
|
|
elif self.destination is None:
|
|
raise ValueError('Delivery requires the full address')
|
|
elif self.destination.postal_code != self.freight.postal_code:
|
|
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
|
|
return self
|
|
|
|
class Review(StrictModel):
|
|
items: list[Item] = Field(min_length=1, max_length=30)
|
|
|
|
class Pay(StrictModel):
|
|
quote_id: UUID
|
|
|
|
class PaymentMethod(StrictModel):
|
|
type: Literal['pix', 'card']
|
|
# Card fields come from the provider's own form, which tokenises the card
|
|
# in the browser; the number never reaches this server.
|
|
token: str | None = Field(default=None, max_length=200)
|
|
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
|
|
installments: int = Field(default=1, ge=1, le=12, strict=True)
|
|
issuer_id: str | None = Field(default=None, max_length=40)
|
|
# The cardholder's document from the card form: for a card, the payer is
|
|
# the cardholder, not necessarily the company on the invoice.
|
|
payer_document_type: Literal['CPF', 'CNPJ'] | None = None
|
|
payer_document: str | None = Field(default=None, pattern=r'^[0-9]{11,14}$')
|
|
|
|
@model_validator(mode='after')
|
|
def card_needs_token(self):
|
|
if self.type == 'card' and not (self.token and self.payment_method_id):
|
|
raise ValueError('Card payment requires the provider token and method')
|
|
return self
|
|
|
|
class PaymentIntent(StrictModel):
|
|
quote_id: UUID
|
|
method: PaymentMethod
|
|
|
|
class Move(StrictModel):
|
|
state: Literal['rec','tra','fil','imp','cor','fin']
|
|
version: int = Field(ge=0)
|
|
reason: str = Field(default='', max_length=1000)
|
|
|
|
class Resolution(StrictModel):
|
|
note: str = Field(min_length=3, max_length=1000)
|
|
|
|
class Register(StrictModel):
|
|
customer: Customer
|
|
password: str = Field(min_length=12, max_length=128)
|
|
|
|
class Login(StrictModel):
|
|
email: str = Field(min_length=3, max_length=254)
|
|
password: str = Field(min_length=1, max_length=128)
|
|
|
|
class OperatorLogin(StrictModel):
|
|
email: str = Field(min_length=3, max_length=254)
|
|
password: str = Field(min_length=1, max_length=128)
|
|
|
|
@field_validator('email')
|
|
@classmethod
|
|
def operator_email_valid(cls, value):
|
|
value = value.strip().lower()
|
|
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
|
|
raise ValueError('Invalid email')
|
|
return value
|
|
|
|
class FileReference(StrictModel):
|
|
upload_id: UUID
|
|
item_index: int = Field(ge=0, strict=True)
|
|
|
|
class ArtworkSubmission(StrictModel):
|
|
version: int = Field(ge=0, strict=True)
|
|
files: list[FileReference] = Field(min_length=1, max_length=60)
|
|
note: str = Field(min_length=1, max_length=1000)
|