Skip to main content

Invoice Extraction with OCI AI: Build an Intelligent Document Processing API

Calculating read time…

What is this project about?

Imagine you work at a shop. Every day, 200 invoices arrive — some as scanned PDFs, some as JPG photos of paper bills. Someone has to read every single one and type the numbers into a computer. That takes hours!

Now imagine a robot that reads every invoice in 3 seconds, pulls out all the key fields, and gives you a clean, organised answer. That's what we're building!

🧒 Think of it like: A super-smart robot assistant that reads your bill, circles the important numbers, and hands you a tidy summary card — instantly!

What will our system do?

  • 📤 Accept an invoice image (JPG, PNG, or PDF) sent over the internet
  • ☁️ Store it safely in Oracle Cloud's Object Storage (like a cloud hard drive)
  • 🤖 Send it to OCI Document Understanding — Oracle's pre-trained AI that reads documents
  • 📋 Receive back all the extracted fields: vendor name, invoice number, date, totals, line items
  • 📦 Return a clean JSON response — a structured list of all the information found

Why is this useful in real life?

  • 🏦 Banks use it to automatically process loan documents
  • 🏢 Companies use it to automate their accounting departments
  • 🏥 Hospitals use it to process insurance claim forms
  • 🛒 E-commerce companies use it to handle supplier invoices at scale


Section 1 — 🗺️ Architecture: The Full Story

Before writing a single line of code, let's understand the big picture. Think of it as understanding the layout of a theme park before riding any ride!

The Flow — Step by Step Story

 ┌─────────────────────────────────────────────────────────────────────┐
 │                    INVOICE EXTRACTION SYSTEM                        │
 │                                                                     │
 │  [YOU / CLIENT APP]                                                 │
 │     → Sends invoice image via HTTP POST request                     │
 │            ↓                                                        │
 │  [FASTAPI APPLICATION]  ← the brain of our system                   │
 │     → Receives the image file                                       │
 │     → Uploads it to OCI Object Storage (cloud hard drive)           │
 │            ↓                                                        │
 │  [OCI OBJECT STORAGE]                                               │
 │     → Stores the image safely                                       │
 │     → Tells Document Understanding: "Hey, process this file!"       │
 │            ↓                                                        │
 │  [OCI DOCUMENT UNDERSTANDING AI]                                    │
 │     → Reads the invoice using AI models                             │
 │     → Extracts: Invoice No, Vendor, Date, Amount, Line Items...     │
 │     → Writes results as JSON to a results bucket                    │
 │            ↓                                                        │
 │  [FASTAPI APPLICATION]                                              │
 │     → Reads the result JSON from the bucket                         │
 │     → Cleans it up into a nice format                               │
 │            ↓                                                        │
 │  [YOU / CLIENT APP]                                                 │
 │     ← Receives clean JSON with all extracted invoice data! 🎉       │
 └─────────────────────────────────────────────────────────────────────┘

The Key Players (Services We Use)

  • FastAPI — Our Python web server. It listens for invoice uploads and coordinates everything. You Like a manager who receives your order and tells each department what to do.
  • OCI Object Storage — Stores the uploaded invoice files. You  Like a huge filing cabinet in the cloud.
  • OCI Document Understanding — Oracle's AI that reads and understands documents. You  Like a brilliant robot accountant who has read millions of invoices and knows exactly where to look.
  • Docker — Packages our whole app into one portable box. You  Like putting your entire project into a lunchbox that works anywhere!
  • OCI Compute — A cloud server where we run our Docker container. You  Like renting a computer in Oracle's warehouse.

Section 2 — 🔑 OCI Setup (Creating Your Cloud Workspace)

Before building anything, we need to set up our space in Oracle Cloud. 🧒 Think of this like: Before cooking, you prepare your kitchen — get your utensils out, find your apron, and make sure the oven works!

Step 3.1 — Create a Free OCI Account

Go to oracle.com/cloud/free and sign up. You get $300 free credits and always-free services. Have a credit card ready (for verification only — you won't be charged during the free tier).

Step 3.2 — Create a Compartment

A Compartment is like a folder that organises everything in OCI. All our project resources (storage, AI, compute) will live inside one compartment.

  • OCI Console → Identity & Security → Compartments → Create Compartment
  • Name: invoice-project
  • Click Create

📋 After creating, copy the Compartment OCID (the unique ID) — you'll need it later. It looks like: ocid1.compartment.oc1..aaaaaaaaxxx

Step 3.3 — Create Two Object Storage Buckets

We need two buckets (think of them as two drawers):

  • Bucket 1: invoice-input — stores the invoice images users upload
  • Bucket 2: invoice-results — stores the AI extraction results (JSON files)

For each bucket:

  • OCI Console → Storage → Object Storage → Buckets → Create Bucket
  • Select your compartment invoice-project
  • Create both: invoice-input and invoice-results

Step 3.4 — Get Your Tenancy Namespace

The Namespace is a unique ID for your OCI account's Object Storage. Find it at: OCI Console → Profile (top right) → Tenancy → Object Storage Namespace. It looks like: axabc9efgh5x Write this down — you need it in the code!

Step 3.5 — Generate an API Key

Our Python code needs to authenticate with OCI (prove it's allowed to use these services). We do this with an API Key pair — a public key (shared with OCI) and a private key (kept secret on your machine).

  • OCI Console → Profile → My Profile → API Keys → Add API Key
  • Select "Generate API key pair"
  • Download the Private Key — save it as ~/.oci/oci_api_key.pem
  • Click Add — OCI shows you the config file snippet. Copy it!

Step 3.6 — Create the OCI Config File

Create a file at ~/.oci/config (in your home folder, inside a folder named .oci). Paste the snippet OCI gave you:

[DEFAULT]
user=ocid1.user.oc1..aaaaaaaaYOUR_USER_OCID_HERE
fingerprint=aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99
tenancy=ocid1.tenancy.oc1..aaaaaaaaYOUR_TENANCY_OCID_HERE
region=us-ashburn-1
key_file=~/.oci/oci_api_key.pem

🧒 Think of this config file like: Your ID card and house key in one. OCI checks this file to confirm: "Yes, this is really you."

Step 3.7 — Create an IAM Policy for Document Understanding

OCI requires explicit permission for services to talk to each other. We need to allow Document Understanding to access our Object Storage buckets.

  • OCI Console → Identity & Security → Policies → Create Policy
  • Name: invoice-project-policy
  • Add these statements (replace invoice-project with your compartment name):
allow any-user to manage ai-service-document-family in compartment invoice-project
allow any-user to manage object-family in compartment invoice-project
allow any-user to read buckets in compartment invoice-project
✅ Pro Tip: In production, you would restrict these policies to specific users or groups. For learning purposes, any-user lets you get started quickly without complex IAM setup.

Section 3 — 🗂️ Project Files — The Blueprint

Before writing code, let's understand what files we'll create and what each one does. 🧒 Think of it like: Planning a LEGO build — first, you lay out all the pieces on the table and understand what each one is for!

invoice-extractor/            ← Our project folder (the LEGO box)
│
├── app.py                    ← The BRAIN — FastAPI web server code
├── oci_services.py           ← Helper: talks to OCI Object Storage
├── document_ai.py            ← Helper: talks to OCI Document Understanding AI
├── config.py                 ← Settings: all OCI config values in one place
├── requirements.txt          ← Shopping list of Python packages
├── Dockerfile                ← Recipe for packaging everything into Docker
└── .env                      ← Secret values (bucket names, namespace, etc.)

What each file does:

  • app.py → The brain. Receives API requests, calls helpers, returns JSON. Like the manager of a restaurant.
  • oci_services.py → Handles uploading files to Object Storage. Like the filing clerk.
  • document_ai.py → Sends files to OCI AI and reads back results. Like the robot reader.
  • config.py → All your OCI settings in one place. Like a settings page on a phone.
  • requirements.txt → The shopping list of Python libraries needed. Like a grocery list.
  • Dockerfile → Packaging instructions. Like a recipe for building the lunchbox.
  • .env → Private settings (namespace, bucket names). Like a sticky note with passwords — never share this!

Section 4 — ⚙️ config.py — All Settings in One Place

What this file does: It reads all our OCI settings from environment variables (or from a .env file). This way, our code never has hardcoded secrets — we just read them from the environment!

Why we need it: Imagine if you wrote your password directly in 10 different places in your code. If you needed to change it, you'd have to find all 10 places! By keeping all settings in one config.py, you change it in ONE place — done.

📝 What the code below does:
This file reads all the OCI details we set up in Section 3 — like the namespace, bucket names, region, and compartment ID. It uses Python's os.environ.get() to read them from environment variables. Later, when we build the Docker container, we'll pass these values in as environment variables — keeping secrets safe!
# config.py — All OCI settings in one place
# This is like the "Settings" page for our entire application.
# We read values from environment variables so secrets are never hardcoded.

import os

class OCIConfig:
    """
    All Oracle Cloud settings live here.
    Each value is read from an environment variable.
    """

    # ── Your OCI tenancy's unique Object Storage namespace ──────────────
    # Find at: OCI Console → Profile → Tenancy → Object Storage Namespace
    # Example: "axabc9efgh5x"
    NAMESPACE: str = os.environ.get("OCI_NAMESPACE", "YOUR_NAMESPACE_HERE")

    # ── The compartment where our resources live ─────────────────────────
    # Find at: OCI Console → Identity → Compartments → Copy OCID
    COMPARTMENT_ID: str = os.environ.get(
        "OCI_COMPARTMENT_ID", "ocid1.compartment.oc1..xxxx"
    )

    # ── Your OCI region code ─────────────────────────────────────────────
    # Examples: us-ashburn-1, eu-frankfurt-1, ap-sydney-1
    REGION: str = os.environ.get("OCI_REGION", "us-ashburn-1")

    # ── Bucket where uploaded invoice images are stored ──────────────────
    INPUT_BUCKET: str = os.environ.get("INPUT_BUCKET", "invoice-input")

    # ── Bucket where AI extraction results (JSON) are stored ─────────────
    RESULTS_BUCKET: str = os.environ.get("RESULTS_BUCKET", "invoice-results")

    # ── Prefix (folder name) inside the results bucket ───────────────────
    RESULTS_PREFIX: str = os.environ.get("RESULTS_PREFIX", "results")

    # ── Path to your OCI config file (API key authentication) ────────────
    # When running locally: ~/.oci/config
    # When running in Docker on OCI Compute: we use Instance Principal auth
    OCI_CONFIG_FILE: str = os.environ.get("OCI_CONFIG_FILE", "~/.oci/config")
    OCI_CONFIG_PROFILE: str = os.environ.get("OCI_CONFIG_PROFILE", "DEFAULT")

Key lines explained:

  • os.environ.get("OCI_NAMESPACE", "YOUR_NAMESPACE_HERE") → Read the value from environment variable named OCI_NAMESPACE. If it's not set, use the default.
  • NAMESPACE, COMPARTMENT_ID, etc. → These become the "settings" that all other files import and use.

Create the .env file

Create a file named .env in your project folder. This is where you put the actual values. Never commit this file to Git! Add it to .gitignore.

# .env — Your private OCI settings (keep this file SECRET!)
# Never share this or push it to GitHub

OCI_NAMESPACE=axabc9efgh5x
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaabcdefghijk
OCI_REGION=us-ashburn-1
INPUT_BUCKET=invoice-input
RESULTS_BUCKET=invoice-results
RESULTS_PREFIX=results

Section 5 — ☁️ oci_services.py — Uploading to Object Storage

What this file does: It handles the two Object Storage tasks our app needs: uploading an invoice image to the input bucket, and reading the AI result JSON from the results bucket.

Why we need it: OCI Document Understanding doesn't accept files directly — it only reads files that are already stored in Object Storage. So we first save the invoice to Object Storage, then tell the AI: "Go read that file I just saved!"

🧒 Analogy: It's like a post office. You can't hand the postman your letter directly — you put it in the mailbox (Object Storage) first. Then the postman (AI) reads and processes it.

📝 What the code below does:
This file has two functions. upload_invoice() takes an invoice file and saves it to our OCI bucket — like putting a letter in the mailbox. read_result_json() reads the AI's result file from the results bucket — like picking up the postman's reply from your mailbox. Both functions use the OCI Python SDK's Object Storage client.
# oci_services.py — Handles talking to OCI Object Storage
# This file uploads invoice files to the cloud and reads back AI results.

import oci
import io
import json
import uuid
from config import OCIConfig

def get_object_storage_client() -> oci.object_storage.ObjectStorageClient:
    """
    Creates and returns an OCI Object Storage client.
    The client handles authentication automatically using your ~/.oci/config file.
    It's like showing your badge at the door — once verified, you can use the service.
    """
    config = oci.config.from_file(
        file_location=OCIConfig.OCI_CONFIG_FILE,
        profile_name=OCIConfig.OCI_CONFIG_PROFILE
    )
    return oci.object_storage.ObjectStorageClient(config)


def upload_invoice(file_bytes: bytes, filename: str) -> str:
    """
    Uploads an invoice file to the OCI Object Storage input bucket.

    HOW IT WORKS:
    1. We create an Object Storage client (shows our badge to OCI)
    2. We give the file a unique name to avoid overwriting old ones
    3. We upload the file to the "invoice-input" bucket
    4. We return the unique object name so we know which file to process next

    Args:
        file_bytes: The raw bytes of the invoice image (the file content)
        filename:   The original filename (e.g., "invoice_001.jpg")

    Returns:
        object_name: The unique name used to store the file in OCI (e.g., "uuid-invoice_001.jpg")
    """
    client = get_object_storage_client()

    # Give the file a unique name by adding a random ID to the front.
    # This prevents two users uploading "invoice.jpg" from overwriting each other.
    unique_id = str(uuid.uuid4())[:8]             # e.g., "a1b2c3d4"
    object_name = f"{unique_id}-{filename}"        # e.g., "a1b2c3d4-invoice.jpg"

    # Upload the file to Object Storage
    # put_object() is the OCI SDK method to upload a file to a bucket
    client.put_object(
        namespace_name = OCIConfig.NAMESPACE,      # Your tenancy's namespace
        bucket_name    = OCIConfig.INPUT_BUCKET,   # "invoice-input" bucket
        object_name    = object_name,              # The filename inside the bucket
        put_object_body= io.BytesIO(file_bytes)    # The actual file content
    )

    print(f"✅ Uploaded invoice to OCI: {object_name}")
    return object_name


def read_result_json(result_object_name: str) -> dict:
    """
    Reads the AI extraction result JSON from the results bucket.

    After Document Understanding finishes processing, it writes a JSON file
    to our "invoice-results" bucket. This function reads that file.

    Args:
        result_object_name: The path of the result JSON file in the results bucket

    Returns:
        A Python dictionary with the full extraction result
    """
    client = get_object_storage_client()

    # get_object() downloads a file from Object Storage — like picking up a letter
    response = client.get_object(
        namespace_name = OCIConfig.NAMESPACE,
        bucket_name    = OCIConfig.RESULTS_BUCKET,
        object_name    = result_object_name
    )

    # response.data.content is the raw bytes of the JSON file
    # We decode it and parse it into a Python dictionary
    result_dict = json.loads(response.data.content.decode("utf-8"))
    return result_dict

Key lines explained:

  • oci.config.from_file() → Reads your ~/.oci/config file to authenticate. Like showing your ID card.
  • uuid.uuid4()[:8] → Generates a random short string so every file gets a unique name.
  • client.put_object() → The OCI SDK method that actually uploads the file.
  • io.BytesIO(file_bytes) → Wraps the file bytes so OCI can read them as a stream.
  • json.loads(response.data.content.decode("utf-8")) → Reads the bytes, converts to text, then parses as JSON.

Section 6 — 🤖 document_ai.py — The AI That Reads Invoices

What this file does: It uses OCI Document Understanding to process an invoice. It creates a "Processor Job" — a task that tells the AI: "Read this invoice and extract all the key fields." Then it waits for the job to finish and reads back the results.

Why we need it: Without this, we'd have to manually read every invoice ourselves. With this, we hand the invoice to Oracle's pre-trained AI model (trained on millions of invoices!) and it does the reading for us.

🧒 Analogy: Imagine you have a very brilliant friend who has memorised thousands of invoices. You show her a new invoice image. She reads it and hands you back a card with all the fields filled in: "Vendor: ABC Shop. Total: $250. Date: April 2026." OCI Document Understanding is that brilliant friend!

📝 What the code below does:
This file creates a Document Understanding "Processor Job" — think of it like submitting a work order to the AI robot. We tell it: which file to read (from Object Storage), what to extract (KEY_VALUE_EXTRACTION for invoices), and where to write the results. We then wait for the job to finish (LIFECYCLE_STATE_SUCCEEDED), find the result file in OCI Object Storage, and return the extracted data.
# document_ai.py — Sends invoice to OCI Document Understanding AI for processing
# This is the robot reader that extracts all key fields from the invoice.

import oci
import uuid
import time
from config import OCIConfig

def get_document_client():
    """
    Creates an OCI Document Understanding client.
    Uses the composite operations version so we can use the helpful
    create_processor_job_and_wait_for_state() method that waits for completion.
    """
    config = oci.config.from_file(
        file_location=OCIConfig.OCI_CONFIG_FILE,
        profile_name=OCIConfig.OCI_CONFIG_PROFILE
    )
    # AIServiceDocumentClientCompositeOperations wraps the regular client
    # and adds "wait_for_state" helpers — very useful for async jobs!
    return oci.ai_document.AIServiceDocumentClientCompositeOperations(
        oci.ai_document.AIServiceDocumentClient(config=config)
    )


def extract_invoice_data(object_name: str) -> dict:
    """
    Sends an invoice file (already in Object Storage) to OCI Document Understanding
    for key-value extraction and returns the structured results.

    HOW IT WORKS:
    1. Create an "object_location" pointing to the invoice in our input bucket
    2. Create an "output_location" pointing to our results bucket
    3. Set the feature to KEY_VALUE_EXTRACTION with document_type=INVOICE
    4. Submit a ProcessorJob and WAIT until it succeeds
    5. Find the result JSON file in the results bucket
    6. Read it and return the extracted fields

    Args:
        object_name: The filename of the invoice in the "invoice-input" bucket

    Returns:
        A dict with all extracted invoice fields
    """
    client = get_document_client()

    # ── Step 1: Tell the AI WHERE the invoice file is ────────────────────
    # ObjectLocation says: "The invoice is in THIS bucket, with THIS filename"
    object_location = oci.ai_document.models.ObjectLocation()
    object_location.namespace_name = OCIConfig.NAMESPACE
    object_location.bucket_name    = OCIConfig.INPUT_BUCKET
    object_location.object_name    = object_name       # e.g., "a1b2c3d4-invoice.jpg"

    # ── Step 2: Tell the AI WHERE to write the results ───────────────────
    # OutputLocation says: "Write the JSON results into THIS bucket, with THIS prefix"
    output_location = oci.ai_document.models.OutputLocation()
    output_location.namespace_name = OCIConfig.NAMESPACE
    output_location.bucket_name    = OCIConfig.RESULTS_BUCKET
    output_location.prefix         = OCIConfig.RESULTS_PREFIX  # e.g., "results"

    # ── Step 3: Choose the AI Feature — KEY_VALUE_EXTRACTION for INVOICES ─
    # DocumentKeyValueExtractionFeature tells the AI:
    # "I want you to find all the key-value pairs in this document"
    # When we also set document_type="INVOICE", it uses the invoice-specific model
    # which knows to look for: Invoice Number, Vendor, Date, Amount, Tax, etc.
    key_value_feature = oci.ai_document.models.DocumentKeyValueExtractionFeature()

    # ── Step 4: Build the full Processor Job request ──────────────────────
    # This is like filling out a work order form for the AI robot:
    # - display_name: a unique label for this job
    # - compartment_id: which OCI compartment to bill this to
    # - input_location: where is the invoice?
    # - output_location: where should results go?
    # - processor_config: what kind of processing to do (GENERAL + INVOICE + KEY_VALUE)
    create_processor_job_details = oci.ai_document.models.CreateProcessorJobDetails(
        display_name    = f"invoice-job-{uuid.uuid4()}",
        compartment_id  = OCIConfig.COMPARTMENT_ID,
        input_location  = oci.ai_document.models.ObjectStorageLocations(
            object_locations=[object_location]
        ),
        output_location = output_location,
        processor_config= oci.ai_document.models.GeneralProcessorConfig(
            features      = [key_value_feature],
            document_type = "INVOICE"    # Use the INVOICE-specific AI model
        )
    )

    # ── Step 5: Submit the job and WAIT for it to complete ────────────────
    # create_processor_job_and_wait_for_state() is the "submit and wait" helper.
    # It keeps checking the job status until it reaches SUCCEEDED (or FAILED).
    # This can take 10–60 seconds depending on the invoice complexity.
    print(f"🤖 Submitting invoice to OCI Document Understanding AI...")
    response = client.create_processor_job_and_wait_for_state(
        create_processor_job_details=create_processor_job_details,
        wait_for_states=[oci.ai_document.models.ProcessorJob.LIFECYCLE_STATE_SUCCEEDED],
        waiter_kwargs={"max_wait_seconds": 300}    # Wait up to 5 minutes
    )

    job_id = response.data.id
    print(f"✅ AI Job completed! Job ID: {job_id}")

    # ── Step 6: Find and return the result file ───────────────────────────
    # When the AI finishes, it writes a JSON file to our results bucket.
    # The file path format is: {prefix}/{namespace}_{input_bucket}/{job_id}/results/
    # We need to find the exact filename and read it.
    result = parse_extraction_result(object_name, job_id)
    return result


def parse_extraction_result(object_name: str, job_id: str) -> dict:
    """
    Reads the AI extraction result from Object Storage and formats it nicely.

    The AI writes results in a complex nested JSON. This function reads that JSON,
    finds the key-value pairs on the first page, and returns a clean dict.

    Args:
        object_name: Original invoice object name
        job_id:      The processor job ID (used to find the result file path)

    Returns:
        A clean dict with invoice fields like:
        {"invoice_number": "INV-2026-001", "vendor_name": "ACME Corp", ...}
    """
    # Import here to avoid circular imports
    from oci_services import read_result_json

    # The result JSON file path in the results bucket follows this pattern:
    # {prefix}/{namespace}_{input_bucket}/{job_id}/results/{object_name}.json
    result_object_path = (
        f"{OCIConfig.RESULTS_PREFIX}/"
        f"{OCIConfig.NAMESPACE}_{OCIConfig.INPUT_BUCKET}/"
        f"{job_id}/results/"
        f"{object_name}.json"
    )

    print(f"📂 Reading result file: {result_object_path}")

    # Read the raw result JSON from Object Storage
    raw_result = read_result_json(result_object_path)

    # ── Parse the AI result into a clean format ───────────────────────────
    # The raw result has many pages, each with many fields.
    # We extract the key-value fields from the first page.
    extracted_fields = {}

    pages = raw_result.get("pages", [])
    if pages:
        first_page = pages[0]
        document_fields = first_page.get("documentFields", [])

        for field in document_fields:
            # Each field has a "fieldType" (the label) and "fieldValue" (the value)
            field_type  = field.get("fieldType", {})
            field_value = field.get("fieldValue", {})

            label = field_type.get("text", "unknown_field").lower().replace(" ", "_")
            value = field_value.get("text", None)
            confidence = field_value.get("confidence", 0)

            if value:
                extracted_fields[label] = {
                    "value": value,
                    "confidence": round(confidence * 100, 1)  # e.g., 97.3%
                }

    return extracted_fields

Key lines explained:

  • DocumentKeyValueExtractionFeature() → Tells the AI "extract key-value pairs from this document."
  • document_type="INVOICE" → Tells the AI "use the invoice-specific model." This is critical — without it, the AI uses a generic model that won't know invoice-specific fields.
  • create_processor_job_and_wait_for_state() → The most important call. Submits the job and blocks until it's done.
  • LIFECYCLE_STATE_SUCCEEDED → The status we wait for. Like waiting for a green light.
  • max_wait_seconds=300 → Maximum 5 minutes. If the AI takes longer, we time out gracefully.
  • documentFields → The list of extracted fields in the result JSON (like a list of label: value pairs).

Section 7 — 🧠 app.py — The Brain (FastAPI Web Server)

What this file does: This is the manager of the whole system. It creates the web server that listens for incoming requests. When a user sends an invoice, this file: calls the uploader (to save to OCI), calls the AI (to process), and returns the clean JSON.

Why FastAPI? FastAPI is a modern Python web framework that is incredibly fast, easy to write, and automatically creates documentation for your API. 🧒 Like a super-smart receptionist who answers the door, routes visitors to the right room, and handles paperwork automatically.

📝 What the code below does:
This creates a FastAPI application with one main endpoint: POST /extract-invoice. When a user sends a file to this endpoint, the app saves it to OCI Object Storage, then sends it to the OCI Document Understanding AI, waits for the result, and returns a structured JSON. It also has a GET /health endpoint so cloud platforms can check if the app is alive.
# app.py — The brain of our Invoice Extraction API
# This FastAPI application receives invoice files, processes them through OCI AI,
# and returns structured JSON with all the extracted invoice data.

import time
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.responses import JSONResponse
from oci_services import upload_invoice
from document_ai import extract_invoice_data

# ── Create the FastAPI application ───────────────────────────────────────────
# FastAPI() creates the web server object.
# The title and description appear in the auto-generated API docs at /docs
app = FastAPI(
    title="Invoice Extraction API",
    description="Upload an invoice (JPG/PNG/PDF) → Get structured JSON with all key fields",
    version="1.0.0"
)


# ── ENDPOINT 1: Health Check ──────────────────────────────────────────────────
# GET /health → Returns {"status": "healthy"}
# Cloud platforms (Container Instances, Kubernetes) call this to confirm the app is alive.
# If this endpoint stops responding, the platform restarts the container automatically.
@app.get("/health")
def health_check():
    return {"status": "healthy", "service": "invoice-extraction-api"}


# ── ENDPOINT 2: Extract Invoice Data ──────────────────────────────────────────
# POST /extract-invoice → Main endpoint
# The user sends an invoice file, we return extracted JSON data.
# This is the ENTIRE workflow in one endpoint!
@app.post("/extract-invoice")
async def extract_invoice(file: UploadFile = File(...)):
    """
    Upload an invoice image (JPG, PNG, PDF) and get back structured JSON
    with all extracted key-value pairs.

    HOW THE FLOW WORKS:
    1. User sends invoice file via HTTP POST (multipart/form-data)
    2. We validate the file type
    3. We read the file bytes
    4. We upload to OCI Object Storage (invoice-input bucket)
    5. We send the stored file to OCI Document Understanding AI
    6. We wait for the AI to finish extracting data
    7. We return clean JSON with all extracted fields
    """

    # ── Step 1: Validate the file type ───────────────────────────────────
    # We only accept common invoice formats: PDF, JPG, JPEG, PNG, TIFF
    allowed_types = ["image/jpeg", "image/jpg", "image/png",
                     "image/tiff", "application/pdf"]

    if file.content_type not in allowed_types:
        # If the user sent a wrong file type (e.g., .mp3), reject it immediately
        raise HTTPException(
            status_code=400,
            detail=f"File type not supported: {file.content_type}. "
                   f"Please upload JPG, PNG, TIFF, or PDF."
        )

    # ── Step 2: Read the file content from the request ────────────────────
    # file.read() reads all the bytes from the uploaded file
    file_bytes = await file.read()

    if not file_bytes:
        raise HTTPException(status_code=400, detail="Uploaded file is empty.")

    # ── Step 3: Upload to OCI Object Storage ─────────────────────────────
    # We save the invoice to OCI first because the AI can only read from OCI storage.
    start_time = time.time()
    print(f"📤 Uploading invoice: {file.filename} ({len(file_bytes)} bytes)")

    try:
        object_name = upload_invoice(file_bytes, file.filename)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"Failed to upload invoice to OCI Storage: {str(e)}"
        )

    # ── Step 4: Send to OCI Document Understanding AI ────────────────────
    # Now we pass the object_name (the file's location in OCI) to the AI extractor.
    # It will create a ProcessorJob, wait for it, and return the extracted fields.
    print(f"🤖 Sending to OCI Document Understanding AI...")

    try:
        extracted_data = extract_invoice_data(object_name)
    except Exception as e:
        raise HTTPException(
            status_code=500,
            detail=f"AI extraction failed: {str(e)}"
        )

    elapsed = round(time.time() - start_time, 2)

    # ── Step 5: Return the clean JSON response ────────────────────────────
    # We return all extracted fields plus some metadata (filename, time taken, etc.)
    return JSONResponse(content={
        "status":           "success",
        "filename":         file.filename,
        "processing_time":  f"{elapsed}s",
        "extracted_fields": extracted_data,
        "message":          "Invoice processed successfully by OCI Document Understanding AI"
    })


# ── Start the web server (for local testing only) ─────────────────────────────
# When running directly with "python app.py", this starts the server on port 8000.
# In Docker, we use Gunicorn + Uvicorn workers instead (see Dockerfile).
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False)

Key lines explained:

  • @app.post("/extract-invoice") → Tells FastAPI: when someone sends a POST request to /extract-invoice, run this function.
  • UploadFile = File(...) → FastAPI magic that automatically handles multipart file uploads. The ... means this field is required.
  • async def → Makes this an asynchronous function. FastAPI can handle many requests simultaneously this way — very fast!
  • await file.read() → Reads all bytes from the uploaded file. The await is needed because reading is asynchronous.
  • raise HTTPException(status_code=400, ...) → Sends an error response with a helpful message to the user.
  • JSONResponse(content={...}) → Sends a JSON response back to the user.

Section 8 — 📋 requirements.txt — The Shopping List

What this file does: Lists all the Python packages (libraries) our application needs to work. When you (or Docker) runs pip install -r requirements.txt, all packages are installed automatically.

🧒 Think of it like: A grocery shopping list before a big dinner party. Instead of going to the store and guessing what to buy, you write down every single ingredient you need!

📝 What the file below does:
These are the exact Python packages our app needs. oci is the Oracle Cloud SDK. fastapi is our web framework. uvicorn and gunicorn are the web servers. python-multipart enables file upload handling in FastAPI. python-dotenv lets us load the .env file automatically.
# requirements.txt — Python packages needed by our Invoice Extraction API

# Oracle Cloud Infrastructure Python SDK
# Includes modules for: Object Storage, Document Understanding, and more
oci==2.128.0

# FastAPI — our web framework (fast, modern, automatic API docs)
fastapi==0.111.0

# Uvicorn — ASGI server for FastAPI (for local development)
uvicorn[standard]==0.30.1

# Gunicorn — production-grade process manager (used in Docker)
gunicorn==22.0.0

# Required for file upload support in FastAPI (multipart/form-data)
python-multipart==0.0.9

# Loads environment variables from our .env file automatically
python-dotenv==1.0.1

Section 09 — 🐳 Dockerfile — Packaging Everything into a Box

What Docker does: Docker packages your entire application — code, Python version, libraries, settings — into one portable box called a Container Image. This box runs identically on your laptop, your friend's computer, or Oracle's cloud server.

🧒 Perfect analogy: Think of Docker like a magic lunchbox. You pack your sandwich, your juice, your cutlery, your napkins — everything you need for lunch. You hand this lunchbox to anyone in the world, and they can eat the exact same lunch. No missing items. No "it only works in our school cafeteria." It works everywhere!

📝 What the code below does:
This Dockerfile tells Docker exactly how to build our Invoice Extractor image. Step 1: Start with a Python 3.11 environment (like choosing a base kitchen). Step 2: Set up a work folder inside the container. Step 3: Install all Python packages from requirements.txt. Step 4: Copy our application code files in. Step 5: Create a non-root user (security best practice). Step 6: Expose port 8000 so the web server can be reached. Step 7: Start the server using Gunicorn with Uvicorn workers (production setup).
# Dockerfile — Recipe for building the Invoice Extraction API container image
# Each instruction creates one layer in the final image.

# ── Step 1: Start from official Python 3.11 slim image ───────────────────────
# "slim" removes unnecessary OS tools → smaller, faster image
# Python 3.11 is used for best compatibility with the OCI SDK
FROM python:3.11-slim

# ── Step 2: Set metadata labels (optional but good practice) ─────────────────
LABEL maintainer="your-name@email.com"
LABEL description="Invoice Extraction API using OCI Document Understanding"
LABEL version="1.0.0"

# ── Step 3: Set the working directory inside the container ────────────────────
# All following commands run from /app inside the container
WORKDIR /app

# ── Step 4: Install system dependencies (needed by the OCI SDK) ───────────────
# gcc and libffi-dev are needed to compile some OCI SDK dependencies on Linux
RUN apt-get update \
    && apt-get install -y --no-install-recommends gcc libffi-dev \
    && rm -rf /var/lib/apt/lists/*

# ── Step 5: Copy requirements and install Python packages ──────────────────────
# IMPORTANT: Copy requirements.txt BEFORE copying the code.
# Why? Docker caches layers. If only your code changed (not requirements),
# Docker reuses the cached package install layer → much faster rebuilds!
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ── Step 6: Copy all application files into the container ─────────────────────
COPY app.py           .
COPY oci_services.py  .
COPY document_ai.py   .
COPY config.py        .

# ── Step 7: Security — create a non-root user ─────────────────────────────────
# Running as root inside a container is a security risk.
# We create a user "appuser" (ID 1000) and switch to it.
RUN adduser --disabled-password --gecos "" --uid 1000 appuser
USER appuser

# ── Step 8: Expose the port our app listens on ────────────────────────────────
# This is documentation — it doesn't actually open the port.
# You must publish the port when running: docker run -p 8000:8000
EXPOSE 8000

# ── Step 9: The command that starts our app when the container runs ────────────
# Gunicorn is our production process manager.
# --workers 2: Use 2 worker processes to handle multiple requests at once
# -k uvicorn.workers.UvicornWorker: Use Uvicorn as the async worker type (needed for FastAPI)
# --bind 0.0.0.0:8000: Listen on all network interfaces on port 8000
# app:app: In the file "app.py", find the FastAPI object named "app"
CMD ["gunicorn", \
     "--workers", "2", \
     "-k", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000", \
     "--timeout", "300", \
     "app:app"]

Key lines explained:

  • FROM python:3.11-slim → Start from a minimal Python environment. Our base.
  • COPY requirements.txt . then COPY app.py . (separate steps) → The layer caching trick for faster rebuilds.
  • adduser appuser + USER appuser → Switch to a non-root user. Like using a regular employee account instead of an admin account.
  • --workers 2 → Two parallel workers handle two requests simultaneously.
  • -k uvicorn.workers.UvicornWorker → FastAPI needs Uvicorn-style workers (not the default sync workers).
  • --timeout 300 → Allow up to 5 minutes per request (AI processing can take 30–60 seconds).

Also Create a .dockerignore File

📝 What the file below does:
This tells Docker which files to skip when doing COPY . .. We don't want our .env secrets file, local cache, or test files baked into the image!
# .dockerignore — files to exclude from the Docker image

.env               # Contains secrets — NEVER include this in the image!
.git/              # Git history — not needed
__pycache__/       # Python cache — not needed
*.pyc              # Compiled Python files — not needed
tests/             # Test files — not needed in production image
*.md               # Documentation — not needed at runtime
.dockerignore      # This file itself

Section 10 — 🏗️ Build, Tag, and Push to OCI Container Registry

Now we'll build our Docker image, test it locally, and push it to OCIR — Oracle's private image library in the cloud. Once in OCIR, our OCI cloud server can pull and run it.

Step A — Build the Image Locally

📝 What the command below does:
Reads our Dockerfile and builds the image layer by layer. -t invoice-extractor:v1 gives it a name and version tag. The final dot . means "look for Dockerfile in the current folder." This may take 2–3 minutes the first time (downloading base image + installing packages). Later rebuilds are much faster thanks to Docker layer caching!
# Make sure you are inside the invoice-extractor folder
# Then build the Docker image

docker build -t invoice-extractor:v1 .

Step B — Test the Image Locally

📝 What the command below does:
Starts a container from our image on our local laptop. --env-file .env passes all the OCI settings from our .env file into the container as environment variables. -v ~/.oci:/home/appuser/.oci:ro mounts our local OCI config folder into the container (read-only) so authentication works. -p 8000:8000 connects port 8000 on laptop to port 8000 in container.
docker run -d \
  --name invoice-api-test \
  --env-file .env \
  -v ~/.oci:/home/appuser/.oci:ro \
  -p 8000:8000 \
  invoice-extractor:v1

Check that it started correctly:

docker logs invoice-api-test
# You should see: [INFO] Listening at: http://0.0.0.0:8000

# Quick health check:
curl http://localhost:8000/health
# Expected: {"status": "healthy", "service": "invoice-extraction-api"}

When done testing, clean up:

docker stop invoice-api-test
docker rm invoice-api-test

Step C — Tag the Image for OCIR

📝 What the command below does:
Gives our image the full OCIR address as a name, so Docker knows WHERE to push it. Think of it like writing the full delivery address on a package before mailing it. Replace the placeholders with your actual OCI region, namespace, and compartment values.
# Replace: <region> with e.g. us-ashburn-1
#          <namespace> with your tenancy namespace (e.g. axabc9efgh5x)
#          <repo-name> is what you name the repository in OCIR

docker tag invoice-extractor:v1 \
  ocir.<region>.oci.oraclecloud.com/<namespace>/invoice-extractor:v1

# Real example:
# docker tag invoice-extractor:v1 \
#   ocir.us-ashburn-1.oci.oraclecloud.com/axabc9efgh5x/invoice-extractor:v1

Step D — Log In and Push to OCIR

📝 What the commands below do:
First command logs Docker into Oracle's container registry using your Auth Token as the password. (Get an Auth Token from: OCI Console → Profile → My Profile → Auth Tokens → Generate Token). Second command uploads the image to OCIR layer by layer. Once pushed, your OCI cloud server can pull this image and run it!
# Step 1: Log in to OCIR (use Auth Token as password, NOT your OCI Console password!)
docker login ocir.<region>.oci.oraclecloud.com \
  --username '<namespace>/<your-oci-email>'
# When prompted for password: paste your Auth Token

# Step 2: Push the image to OCIR
docker push ocir.<region>.oci.oraclecloud.com/<namespace>/invoice-extractor:v1

✅ Verify in OCI Console → Developer Services → Container Registry — you should see invoice-extractor listed!

Section 11 — 🖥️ Deploy on OCI Compute Instance

We'll deploy our container on an OCI Compute Instance — a virtual machine (VM) running in Oracle's data centre. This is the simplest way to get your container running on the cloud with a public IP address.

🧒 Think of it like: Renting a room in a huge hotel (Oracle's data centre). You bring your lunchbox (Docker image), unpack it in the room, and start serving food (your API) to guests!

Step A — Create an OCI Compute Instance

  • OCI Console → Compute → Instances → Create Instance
  • Name: invoice-api-server
  • Image: Oracle Linux 8 (always free eligible)
  • Shape: VM.Standard.E2.1.Micro (always free!) — 1 OCPU, 1 GB RAM
  • Networking: Choose your VCN + a public subnet
  • Add SSH key: paste your public SSH key (or generate one in the console)
  • Click Create

Wait 2–3 minutes for it to reach RUNNING state. Copy the Public IP address.

Step B — Open Port 8000 in Security List

By default, OCI firewalls block all ports except 22 (SSH). We need to open port 8000:

  • OCI Console → Networking → Virtual Cloud Networks → your VCN → Security Lists → Default Security List
  • Add Ingress Rule: Source CIDR: 0.0.0.0/0, Protocol: TCP, Port: 8000
  • Save

Step C — SSH Into the Server and Install Docker

📝 What the commands below do:
First command connects your terminal to the OCI server over SSH. Then we install Docker on the server (so it can run our container image). Then we log Docker into OCIR (so it can pull our image from Oracle's registry). Then we run the container, passing in all the OCI settings as environment variables.
# Connect to your OCI server (replace with your actual public IP)
ssh -i ~/.ssh/your_private_key opc@<your-server-public-ip>

# ── On the server: Install Docker ──────────────────────────────────────
sudo dnf install -y docker
sudo systemctl start docker
sudo systemctl enable docker        # Auto-start Docker on reboot
sudo usermod -aG docker opc         # Allow "opc" user to run docker without sudo
exit                                # Log out and back in for the group change to apply

# ── Reconnect ──────────────────────────────────────────────────────────
ssh -i ~/.ssh/your_private_key opc@<your-server-public-ip>

# ── On the server: Log in to OCIR ──────────────────────────────────────
docker login ocir.<region>.oci.oraclecloud.com \
  --username '<namespace>/<your-oci-email>'
# Enter your Auth Token when prompted

# ── On the server: Pull and run the container ──────────────────────────
docker run -d \
  --name invoice-api \
  --restart always \
  -p 8000:8000 \
  -e OCI_NAMESPACE="your_namespace" \
  -e OCI_COMPARTMENT_ID="ocid1.compartment.oc1..xxxx" \
  -e OCI_REGION="us-ashburn-1" \
  -e INPUT_BUCKET="invoice-input" \
  -e RESULTS_BUCKET="invoice-results" \
  -e RESULTS_PREFIX="results" \
  -e OCI_CONFIG_FILE="/root/.oci/config" \
  ocir.<region>.oci.oraclecloud.com/<namespace>/invoice-extractor:v1
⚠️ Authentication on the Cloud Server:
On the OCI Compute server, you have two options for authenticating the OCI SDK: (1) Copy your ~/.oci/config and oci_api_key.pem to the server and mount them into the container. (2) Better: Use Instance Principal authentication — the server authenticates itself using its cloud identity, no key files needed! For Instance Principal, set a Dynamic Group + policy, then remove OCI_CONFIG_FILE and modify the code to use oci.auth.signers.InstancePrincipalsSecurityTokenSigner().

Step D — Verify It's Running

# Check logs to confirm startup
docker logs invoice-api

# Test the health endpoint from the server itself:
curl http://localhost:8000/health

# Test from your laptop (replace with actual server IP):
curl http://<server-public-ip>:8000/health
# Expected: {"status": "healthy", "service": "invoice-extraction-api"}

Section 12 — 🌐 Calling the API — Your HTTPS Endpoint

Your API is now live! 🎉 Let's learn exactly how to call it — from the terminal (curl), from Python code, and from Postman.

🧒 Think of your API like a vending machine. The endpoint is the vending machine's address. You put in your invoice (like inserting coins), press the button (POST /extract-invoice), and out comes your JSON (like a snack popping out)!

Method 1 — Using curl (Terminal)

📝 What the command below does:
This sends an HTTP POST request to our API with an invoice file attached. -F "file=@invoice.jpg" attaches the local file invoice.jpg to the request. The @ sign means "send the actual file content, not just the filename." Replace <server-ip> with your OCI server's public IP address.
# Send an invoice image to the API and see the extracted JSON
# Make sure invoice.jpg is in your current folder!

curl -X POST "http://<server-ip>:8000/extract-invoice" \
  -H "accept: application/json" \
  -F "file=@invoice.jpg"

You'll get a JSON response like this:

{
  "status": "success",
  "filename": "invoice.jpg",
  "processing_time": "28.4s",
  "extracted_fields": {
    "invoice_number": {
      "value": "INV-2026-00142",
      "confidence": 98.7
    },
    "vendor_name": {
      "value": "ACME Supplies Ltd",
      "confidence": 97.2
    },
    "invoice_date": {
      "value": "2026-04-15",
      "confidence": 99.1
    },
    "due_date": {
      "value": "2026-05-15",
      "confidence": 96.8
    },
    "total_amount": {
      "value": "$4,250.00",
      "confidence": 99.5
    },
    "tax_amount": {
      "value": "$382.50",
      "confidence": 98.3
    },
    "subtotal": {
      "value": "$3,867.50",
      "confidence": 97.9
    },
    "bill_to": {
      "value": "XYZ Corporation, 123 Business Park, NY 10001",
      "confidence": 95.4
    }
  },
  "message": "Invoice processed successfully by OCI Document Understanding AI"
}

Method 2 — Using Python requests

📝 What the code below does:
This is how you would call your Invoice API from another Python application. requests.post() sends the HTTP POST request with the invoice file attached. The response comes back as JSON, which we print nicely. This is exactly how a larger accounting app would call our microservice!
# test_api.py — How to call our Invoice Extraction API from Python
# Run this from your laptop to test the API running on OCI

import requests
import json

# ── Configuration ────────────────────────────────────────────────────────────
SERVER_IP    = "<your-server-public-ip>"     # Replace with your OCI server IP
API_ENDPOINT = f"http://{SERVER_IP}:8000/extract-invoice"
INVOICE_FILE = "sample_invoice.jpg"             # Path to a test invoice image

# ── Call the API ──────────────────────────────────────────────────────────────
print(f"📤 Sending invoice to: {API_ENDPOINT}")

with open(INVOICE_FILE, "rb") as f:
    # "rb" = read binary — we're sending raw image bytes
    files   = {"file": (INVOICE_FILE, f, "image/jpeg")}
    response = requests.post(API_ENDPOINT, files=files, timeout=120)

# ── Check the response ────────────────────────────────────────────────────────
if response.status_code == 200:
    result = response.json()
    print(f"\n✅ Success! Processing time: {result['processing_time']}")
    print(f"\n📋 Extracted Fields:")

    for field_name, field_data in result["extracted_fields"].items():
        print(f"  {field_name:25} → {field_data['value']}"
              f"  (confidence: {field_data['confidence']}%)")
else:
    print(f"❌ Error: {response.status_code}")
    print(response.text)

Method 3 — Using Postman

  • Open Postman → New Request
  • Method: POST
  • URL: http://<server-ip>:8000/extract-invoice
  • Click Body tab → select form-data
  • Add a key: file, change type from Text to File, select your invoice image
  • Click Send
  • See the JSON response in the bottom panel!

The Auto-Generated API Documentation

FastAPI automatically creates beautiful interactive API documentation! Open your browser to: http://<server-ip>:8000/docs

You'll see a full Swagger UI where you can test the API directly from the browser — upload a file and see the response, all from a nice web interface!

Section 13 — 🧪 Final Testing — Let's Try It!

Let's do a complete end-to-end test to confirm everything works. We'll follow the exact same path as a real user would!

The Complete Test Checklist

  • ✅ Health check: curl http://<ip>:8000/health → returns {"status": "healthy"}
  • ✅ API docs: Open http://<ip>:8000/docs in browser → see Swagger UI
  • ✅ Invoice extraction: Send a JPG invoice → get JSON back
  • ✅ OCI Console: Check invoice-input bucket → you should see the uploaded file
  • ✅ OCI Console: Check invoice-results bucket → you should see the result JSON file
  • ✅ Error handling: Send a .mp3 file → should get a helpful error message
  • ✅ Empty file: Send an empty file → should get a helpful error message

Sample Test — Wrong File Type

# Test with a wrong file type (should get a 400 error with clear message)
curl -X POST "http://<server-ip>:8000/extract-invoice" \
  -F "file=@song.mp3"

# Expected response:
# {
#   "detail": "File type not supported: audio/mpeg.
#              Please upload JPG, PNG, TIFF, or PDF."
# }

Checking Logs

# View live logs of the running container
docker logs -f invoice-api

# You'll see lines like:
# 📤 Uploading invoice: invoice.jpg (524288 bytes)
# ✅ Uploaded invoice to OCI: a1b2c3d4-invoice.jpg
# 🤖 Submitting invoice to OCI Document Understanding AI...
# ✅ AI Job completed! Job ID: ocid1.aidocumentprocessorjob.oc1...
# 📂 Reading result file: results/axabc9efgh5x_invoice-input/...
# INFO: 127.0.0.1 - "POST /extract-invoice HTTP/1.1" 200 OK

Section 14 — 📝 Mini Summary — What We Built and Learned

🎉 Congratulations! You just built a production-grade AI Invoice Extraction API! Let's celebrate by reviewing everything we learned:

What We Built

  • A FastAPI web server with a POST /extract-invoice endpoint
  • OCI Object Storage integration — uploads invoice files to the cloud
  • OCI Document Understanding AI — extracts key-value pairs from invoices automatically
  • A Docker container that packages the whole system portably
  • Deployment on an OCI Compute Instance with a real public IP
  • An HTTP API endpoint accessible from curl, Python, Postman, or any app

The Skills You Practised

  • OCI Setup → Compartments, Object Storage, IAM Policies, API Keys
  • Python → FastAPI, async functions, file handling, OCI SDK
  • OCI AI → Document Understanding, ProcessorJob, key-value extraction
  • Docker → Writing Dockerfiles, building images, container best practices
  • OCIR → Tagging and pushing images to Oracle's container registry
  • Deployment → Running containers on cloud VMs with public endpoints
  • API design → REST endpoints, error handling, health checks, file uploads

Real-World Uses of This System

  • 🏢 Accounting automation — Process hundreds of supplier invoices per hour automatically
  • 🏥 Healthcare billing — Extract data from medical invoices for insurance processing
  • 🛒 E-commerce — Automatically reconcile purchase orders with invoices
  • 🏦 Banking & finance — Loan document processing, compliance checks
  • 📦 Logistics — Automated freight bill processing and customs documentation


Happy building! ☁️🤖✨

a

Comments