Build an Invoicing System

Store invoices as drops, run business logic in functions, generate PDFs server-side — a complete invoicing app with zero infrastructure.

Running Drops The deployment steps in this tutorial use Drops, a compact language that replaces curl commands. Run them via POST /exec or paste into the live editor. The Python function code inside runs on InfiniteOcean's runner fleet.

This tutorial walks through building a real invoicing system entirely on InfiniteOcean. No database, no server, no deploy pipeline. Drops store the data, functions handle the logic, the View Service hosts the frontend, and cron runs scheduled jobs. The result is a production invoicing tool built on a handful of API calls.

1Architecture

The app has four parts, all stored as drops:

WhatRouteHow
Frontend (HTML, JS, CSS)invoDrops served by View Service
API functionfn/invo/apiPython function handling all CRUD
PDF generatorfn/invo/pdfSeparate function, called by API
Overdue checkerfn/invo/overdueCron-triggered function

Every piece of data — accounts, entities, clients, invoices, timeline events — lives in drops under the invo/ route prefix. No external database.

2Set Up Authentication

InfiniteOcean has built-in magic link auth. The frontend sends an email, the user clicks a link, and they get back an Ocean key tied to their identity.

HTTP only Authentication endpoints (/auth/login, /auth/verify, /auth/me) use the REST API directly.
javascript
// Send magic link
const res = await fetch('https://api.infiniteocean.io/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: userEmail,
    callback_url: window.location.origin + window.location.pathname,
    app_name: 'Invo',
    subject: 'Sign in to Invo'
  })
});

When the user clicks the link, they return with a ?token= parameter. Verify it to get their key:

javascript
const { key } = await fetch(
  `https://api.infiniteocean.io/auth/verify?token=${token}`
).then(r => r.json());

// Store the key — this is their session
localStorage.setItem('invo_key', key);

From here, GET /auth/me with the key header returns their email. The API function maps this to an account.

3The API Function

The entire backend is one Python function deployed as a drop. It receives a JSON body with an action field and routes to the right handler.

python
import json
import hashlib

def main(ocean, request):
    action = request.get('action')
    auth_key = request.get('auth_key')

    if action == 'account.init':
        return account_init(ocean, request)

    # Authenticate
    account = get_account_by_key(ocean, auth_key)
    if not account:
        return {'error': 'Not authenticated'}

    account_id = account['account_id']
    handlers = {
        'entity.list':    entity_list,
        'entity.create':  entity_create,
        'client.list':    client_list,
        'client.create':  client_create,
        'invoice.list':   invoice_list,
        'invoice.create': invoice_create,
        'invoice.finalize': invoice_finalize,
        # ... more actions
    }
    handler = handlers.get(action)
    if not handler:
        return {'error': f'Unknown action: {action}'}
    return handler(ocean, account_id, request)

Deploy the function:

drops
write fn/invo/api @main "... paste python source here ..."

Make it publicly callable with write:

drops
write fn/invo/api @_config { execute: "public" }

Now anyone can call it via POST https://api.infiniteocean.io/run/fn/invo/api.

Tip The _config entity with execute: "public" makes the function callable without an Ocean key. The function itself verifies the caller's auth_key internally, so unauthenticated requests get rejected at the application level.

4Storing Data as Drops

Every entity is a drop with a JSON payload. Writing is a POST to /drop:

python
def invoice_create(ocean, account_id, request):
    invoice_id = request.get('invoice_id')
    invoice = {
        'id': invoice_id,
        'account_id': account_id,
        'entity_id': request.get('entity_id'),
        'client_id': request.get('client_id'),
        'number': 'DRAFT',
        'currency': request.get('currency', 'EUR'),
        'lines': request.get('lines', []),
        'total': request.get('total', 0),
        'status': 'draft',
        'created_at': now_iso()
    }
    ocean.write('invo/invoices', json.dumps(invoice), key=invoice_id)
    return {'invoice_id': invoice_id}
ocean.write(route, payload, key)
Creates a drop. The key makes it an entity — a latest-version record you can query.
invo/invoices
The route. All invoices are grouped here, queryable via SQL.

5Reading Data with SQL

InfiniteOcean supports SQL queries over drops. This is how the app lists invoices for an account:

python
def list_by_account(ocean, collection, account_id):
    r = ocean.sql(
        f"SELECT * FROM 'invo/{collection}' "
        f"WHERE account_id = '{account_id}' "
        f"ORDER BY created_at DESC LIMIT 500"
    )
    if not r or r.get('row_count', 0) == 0:
        return []
    cols = r['columns']
    items = []
    for row in r['rows']:
        item = {}
        for i, col in enumerate(cols):
            if col != 'vector':
                item[col] = row[i]
        items.append(item)
    return items
Note Route names with slashes need single quotes in SQL: FROM 'invo/invoices'. Without quotes, the parser treats the slash as division.

Reading a single entity by key:

python
def sql_read(ocean, table, key):
    r = ocean.sql(f"SELECT * FROM '{table}' WHERE key = '{key}' LIMIT 1")
    if not r or r.get('row_count', 0) == 0:
        return None
    cols = r['columns']
    row = r['rows'][0]
    return {col: row[i] for i, col in enumerate(cols) if col != 'vector'}

6Invoice Lifecycle

An invoice moves through states: draft → finalized → sent → paid (or voided). Finalizing assigns a sequential number and locks the invoice:

python
def invoice_finalize(ocean, account_id, request):
    invoice = read_entity(ocean, 'invoices', request.get('invoice_id'))
    if invoice.get('status') != 'draft':
        return {'error': 'Only draft invoices can be finalized'}

    # Assign sequential number from entity counter
    entity = read_entity(ocean, 'entities', invoice.get('entity_id'))
    seq = (entity.get('invoice_seq') or 0) + 1
    year = datetime.now(timezone.utc).year
    number = f'INV-{year}-{str(seq).zfill(3)}'

    entity['invoice_seq'] = seq
    ocean.write('invo/entities', json.dumps(entity), key=entity_id)

    invoice['number'] = number
    invoice['status'] = 'finalized'
    ocean.write('invo/invoices', json.dumps(invoice), key=invoice_id)
    return {'ok': True, 'number': number}

Every state change writes a timeline event to a per-invoice event stream:

python
def add_event(ocean, invoice_id, event_type):
    event = json.dumps({
        'type': event_type,
        'at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')
    })
    ocean.write(f'invo/invoice-events/{invoice_id}', event)

These accumulate as drops (no key = append-only), building an audit trail.

7PDF Generation

The PDF generator is its own function at fn/invo/pdf. It builds a real PDF using only Python stdlib — no third-party libraries.

python
def main(ocean, request):
    inv = request.get('invoice', {})
    entity = request.get('entity', {})
    client = request.get('client', {})
    filename = request.get('filename', 'invoice.pdf')

    pdf_bytes = generate_pdf(inv, entity, client)

    # Upload as blob
    result = ocean.upload('invo/pdfs', pdf_bytes, filename)
    blob_id = result.get('drop_id')
    return {'blob_id': blob_id, 'filename': filename}

The API calls it on finalize via ocean.run():

python
pdf_result = ocean.run('fn/invo/pdf', {
    'invoice': invoice,
    'entity': entity,
    'client': client,
    'filename': f'{number}.pdf'
})

The PDF is stored as a blob. Download it at GET /blob/{blob_id} — a direct link with proper content-type headers. No intermediate step, no print dialog.

Tip The generator builds raw PDF objects with Helvetica fonts, positioned text, and horizontal rules — all with io.BytesIO, struct, and string formatting. About 200 lines for a clean, professional invoice layout.

8Hosting the Frontend

The frontend is vanilla JavaScript — no build step. Deploy every file as a drop under the invo route:

drops
-- Deploy index.html as the default page
write invo @index "... paste index.html ..."

-- Deploy JS
write invo @app.js "... paste app.js ..."

The View Service at view.infiniteocean.io serves these directly:

URLResolves to
view.infiniteocean.io/invo/route invo, key index
view.infiniteocean.io/invo/app.jsroute invo, key app.js
view.infiniteocean.io/invo/style.cssroute invo, key style.css

Content-type is detected from the key extension: .js serves as application/javascript, .css as text/css. The key index is the default for trailing-slash URLs.

9Scheduled Jobs with Cron

A daily cron job detects overdue invoices:

python
def main(ocean, request):
    from datetime import datetime, timezone
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')

    r = ocean.sql(
        "SELECT * FROM 'invo/invoices' "
        "WHERE status IN ('sent', 'finalized', 'viewed') "
        f"AND due_date < '{today}' LIMIT 1000"
    )
    if not r or r.get('row_count', 0) == 0:
        return {'ok': True, 'invoices_marked_overdue': 0}

    marked = 0
    for row in r['rows']:
        # ... mark as overdue, write timeline event
        marked += 1
    return {'ok': True, 'invoices_marked_overdue': marked}

Deploy the function, then set up the cron:

drops
cron "0 7 * * *" invo-overdue: run fn/invo/overdue

Runs every morning at 7 AM UTC. Standard 5-field cron syntax.

10VAT Calculation and VIES Verification

The app handles EU VAT rules client-side: domestic VAT, reverse charge for intra-EU B2B, zero-rate for exports. VAT numbers are verified against the EU VIES service from within a function:

python
def client_vat_verify(ocean, account_id, request):
    client = read_entity(ocean, 'clients', request.get('client_id'))
    vat_number = client.get('vat_number', '')
    country_code = vat_number[:2].upper()
    number = vat_number[2:]

    import urllib.request
    req_data = json.dumps({
        'countryCode': country_code,
        'vatNumber': number
    }).encode('utf-8')
    req = urllib.request.Request(
        'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number',
        data=req_data,
        headers={'Content-Type': 'application/json'}
    )
    resp = urllib.request.urlopen(req, timeout=10)
    result = json.loads(resp.read().decode('utf-8'))

    client['vat_verified'] = result.get('valid', False)
    ocean.write('invo/clients', json.dumps(client), key=client_id)
    return {'vat_valid': client['vat_verified']}

Functions can call external APIs with urllib.request — no special setup needed.

11Universal Fields

InfiniteOcean's Universal Fields automatically index structured data across routes. Annotate your invoices with standard fields to enable cross-route queries:

drops
-- When creating an invoice, annotate with universal fields
write invo/invoices @INV-2026-001 {
  client: "Acme Corp",
  total: 2500,
  currency: "EUR",
  status: "sent",
  due_date: "2026-04-01",
  _amounts: [{ value: 2500, unit: "EUR", label: "total" }],
  _dates: [{ value: "2026-04-01", label: "due" }],
  _identifiers: [{ value: "INV-2026-001", type: "invoice" }]
}

Then query across all routes using the primitives command:

drops
-- Find all invoices over 1000 EUR due this month
primitives amounts { min: 1000, unit: "EUR" } dates { from: "2026-03-01", to: "2026-03-31", label: "due" }

Universal Fields work across routes — if you also have expenses, purchase orders, or quotes stored as drops, the same query finds them all. No joins needed.

12What You End Up With

A complete invoicing system:

Magic Link Auth

No passwords. Email-based sign in with session keys.

Multi-Entity

Invoice from different companies under one account.

Client Management

With EU VAT verification via VIES.

Invoice Editor

Autosaving drafts, line items, VAT calculation.

PDF Generation

Real PDFs via server-side Python, stored as blobs.

Full Lifecycle

Draft → finalized → sent → paid/voided.

Timeline

Append-only event log per invoice.

Overdue Detection

Daily cron job marks overdue invoices.

Zero Infrastructure

Everything is drops, functions, and blobs.

Universal Fields

Cross-route queries on amounts, dates, and identifiers.

The entire deploy is a series of write commands. Update any file and it's live instantly.

Platform Features Used

FeatureHow it's used
DropsAll data storage (invoices, clients, entities, events)
Entities (keyed drops)Latest state for each record
SQLQuerying and listing data
FunctionsAPI logic, PDF generation, overdue detection
ocean.run()API function calling PDF function
ocean.upload()Storing generated PDFs as blobs
BlobsPDF file storage with direct download URLs
View ServiceHosting the frontend (HTML, JS, CSS)
Magic Link AuthUser authentication
CronScheduled overdue invoice detection
Universal FieldsCross-route queries on invoice amounts, dates, identifiers
All of InfiniteOcean in one app Drops, entities, SQL, functions, blobs, views, auth, cron, universal fields — every major feature used in a single production application.