Build an Invoicing System
Store invoices as drops, run business logic in functions, generate PDFs server-side — a complete invoicing app with zero infrastructure.
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:
| What | Route | How |
|---|---|---|
| Frontend (HTML, JS, CSS) | invo | Drops served by View Service |
| API function | fn/invo/api | Python function handling all CRUD |
| PDF generator | fn/invo/pdf | Separate function, called by API |
| Overdue checker | fn/invo/overdue | Cron-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.
/auth/login, /auth/verify, /auth/me) use the REST API directly.
// 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:
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.
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:
write fn/invo/api @main "... paste python source here ..."
Make it publicly callable with write:
write fn/invo/api @_config { execute: "public" }
Now anyone can call it via POST https://api.infiniteocean.io/run/fn/invo/api.
_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:
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:
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
FROM 'invo/invoices'. Without quotes, the parser treats the slash as division.
Reading a single entity by key:
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:
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:
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.
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():
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.
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:
-- 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:
| URL | Resolves to |
|---|---|
view.infiniteocean.io/invo/ | route invo, key index |
view.infiniteocean.io/invo/app.js | route invo, key app.js |
view.infiniteocean.io/invo/style.css | route 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:
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:
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:
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:
-- 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:
-- 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
| Feature | How it's used |
|---|---|
| Drops | All data storage (invoices, clients, entities, events) |
| Entities (keyed drops) | Latest state for each record |
| SQL | Querying and listing data |
| Functions | API logic, PDF generation, overdue detection |
ocean.run() | API function calling PDF function |
ocean.upload() | Storing generated PDFs as blobs |
| Blobs | PDF file storage with direct download URLs |
| View Service | Hosting the frontend (HTML, JS, CSS) |
| Magic Link Auth | User authentication |
| Cron | Scheduled overdue invoice detection |
| Universal Fields | Cross-route queries on invoice amounts, dates, identifiers |