Multi-currency support
Real invoices come from vendors around the world. A European supplier bills in EUR, a Japanese manufacturer in JPY, a UK freelancer in GBP. Your extraction pipeline needs to detect the currency from the document itself, not assume everything is USD.
Currency detection flow
How the extraction pipeline handles different currencies.
class InvoiceData(BaseModel):
"""Structured data extracted from an invoice"""
# ... other fields ...
currency: Optional[str] = Field(
"USD",
description="Currency of the invoice (e.g., USD, EUR, GBP)"
)
# The prompt guides currency detection:
# "- currency: string (3-letter code, default USD)"
#
# The model reads currency symbols, vendor addresses,
# and country context to determine the correct code.
# If ambiguous, it falls back to USD.The currency field defaults to USD but the model overrides it when it detects non-dollar symbols or contextual clues like a European vendor address.
provider_name = getattr(self.llm_provider, "provider_name", "")
# PDF support varies by provider:
# - Gemini: native PDF support (best choice for documents)
# - OpenAI-compatible (Fireworks, OpenRouter): images only
if mime_type == "application/pdf" and "gemini" not in provider_name.lower():
logger.warning(
f"PDF input not supported for provider '{provider_name}'. "
"Please use an image (PNG/JPG)."
)
return InvoiceData(
is_invoice=False,
vendor_name="Error: PDF not supported with this provider."
)Not every vision provider handles PDFs. Gemini supports them natively, but OpenAI-compatible providers only accept images. The code checks the provider and returns a graceful error instead of crashing.
You could, and that is a valid production strategy. Libraries like pdf2image convert each page to a PNG. The tradeoff is added complexity and dependencies (you need poppler installed). For this project, we keep it simple: use Gemini for PDFs, or ask users to upload images. In a production system, you would add the conversion step so every provider works with every file type.
@router.post("/upload", response_model=InvoiceData)
async def upload_invoice(file: UploadFile = File(...)):
temp_file_path = None
try:
allowed_extensions = {'.pdf', '.png', '.jpg', '.jpeg', '.webp'}
file_extension = os.path.splitext(file.filename)[1].lower()
if file_extension not in allowed_extensions:
raise HTTPException(status_code=400, detail="Unsupported file type")
temp_dir = tempfile.mkdtemp()
temp_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}{file_extension}")
with open(temp_file_path, "wb") as f:
f.write(await file.read())
mime_type = invoice_utils.get_mime_type(temp_file_path)
base64_content = invoice_utils.encode_image_to_base64(temp_file_path)
result = await invoice_analyzer.analyze_invoice(base64_content, mime_type)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.remove(temp_file_path)
os.rmdir(os.path.dirname(temp_file_path))The upload endpoint validates the file extension, saves to a temp file, encodes it, sends it to the analyzer, and cleans up. The finally block ensures temp files are always deleted, even on errors.
Matching exercise: Match the currency challenge to its solution
Loading practice…