InfiniteOceanBeta
Dashboard Start free

Build an Invoicing System

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

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.

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 it:

bash
curl -X POST https://api.infiniteocean.io/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d "$(jq -n --arg code "$(cat api.py)" \
      '{route: "fn/invo/api", key: "main", payload: $code}')"

Make it publicly callable (the function handles its own auth):

bash
curl -X POST https://api.infiniteocean.io/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d '{
    "route": "fn/invo/api",
    "key": "_config",
    "payload": "{\"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:

bash
# Deploy index.html as the default page (key: "index")
curl -X POST https://api.infiniteocean.io/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d "$(jq -n --arg payload "$(cat src/index.html)" \
      '{route: "invo", key: "index", payload: $payload}')"

# Deploy JS and CSS
curl -X POST https://api.infiniteocean.io/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d "$(jq -n --arg payload "$(cat src/app.js)" \
      '{route: "invo", key: "app.js", payload: $payload}')"

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:

bash
curl -X POST https://api.infiniteocean.io/cron \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: YOUR_KEY" \
  -d '{
    "name": "invo-overdue",
    "route": "fn/invo/overdue",
    "schedule": "0 7 * * *",
    "body": {},
    "enabled": true
  }'

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.

11What 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.

The entire deploy is a bash script that POSTs files to /drop. 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
All of InfiniteOcean in one app Drops, entities, SQL, functions, blobs, views, auth, cron — every major feature used in a single production application.