All posts
EngineeringAugust 4, 20267 min read

The Cruiser Aviation Platform - Stack Choices, Benefits, Trades

A deep dive into the AWS-backed stack powering the rebuilt Cruiser Aviation platform: from Next.js 16 App Router conventions and Aurora Serverless v2 to a dual-country billing abstraction and a pure-functional FIFO hours ledger.

The Cruiser Aviation Platform - Stack Choices, Benefits, Trades

Architecture at a glance

CloudFront (TLS, static caching for _next/static only)
  → ALB → ECS Fargate ARM64 (Next.js 16 standalone container, 2 tasks prod / 1 staging)
      → Aurora Serverless v2 Postgres 17 (isolated subnets, Data API on)
      → S3 (presigned up/downloads) · SES · Cognito · Secrets Manager
EventBridge crons → API destination → /api/cron/* (x-cron-secret)
WhatsApp: Meta Cloud API ⇢ AWS End User Messaging Social → SNS → Lambda
      → DynamoDB (sessions/rate limits) + Bedrock (Claude) + RDS Data API
      → internal /api/wa/* (x-wa-secret) → replies
CI: GitHub Actions (OIDC, no stored keys) → ECR → cdk deploy -c imageTag=<git sha>

Two AWS accounts (staging, prod), 9 CDK stacks each, region eu-central-1.

Stack choices - with the benefit and the trade

Next.js 16, App Router, strict TS. One repeated module convention across ~20 modules: page.tsx (RSC, role gate + composition) / query.ts (import "server-only", all reads) / actions.ts ("use server", all writes, each re-checking its role) / lib.ts (pure, client-safe) / *-form.tsx (client leaves). Counts: 83 "use client" files, 51 server-only, 20 "use server". Server actions for anything session-authed; route handlers only where there is no session (webhooks, cron, internal APIs, file streaming). Trade: RSC/actions discipline is a real learning curve; the payoff is that DB access physically can't leak into components.

Deliberate minimalism. Zero test framework (29+ tests run with bare node --test, loading .ts via Node type-stripping), zero date library (a repo rule - Intl-based Bucharest wall-clock helpers in tz.ts), zero form library, zero validation library. NOAA sunset math implemented in 64 lines rather than adding a dependency. Trade: you own more code; benefit: the pure-lib layer (phone.ts, sun/tz/windows.ts, fifo.ts) is loadable from three runtimes (Next.js, cron, the infra Lambda) with no build step.

Drizzle ORM. Schema in TS (38 tables, 31 enums), 15 SQL migrations as the only schema path, casing: "snake_case". What Drizzle can't express (exclusion constraints, CREATE EXTENSION btree_gist) lives as raw SQL appended to the init migration - pragmatism over purity.

Aurora Serverless v2. The config is a one-line cost story:

// staging pauses to 0 ACU when idle; prod keeps a warm floor
serverlessV2MinCapacity: isProd ? 0.5 : 0,
serverlessV2MaxCapacity: isProd ? 4 : 2,
enableDataApi: true,

enableDataApi: true quietly shaped the whole ETL: every script talks HTTPS to the DB with IAM auth - no bastion host, no SSH tunnel, no allowlisted laptop IP. Trade: 0-ACU wake-ups are slow, so retry loops and a 120 s Lambda timeout exist solely for staging's benefit.

Fargate ARM64 + CloudFront. 0.5 vCPU / 1 GB per task; deploys pinned to a git SHA (never latest); ECS circuit breaker with auto-rollback. One CloudFront quirk worth noting - AI latency shaping CDN config:

// License-scan extraction is a synchronous server action (~15-25 s on
// Opus 4.6); the CloudFront default of 30 s cuts it too close.
readTimeout: cdk.Duration.seconds(60),

CDK + GitHub OIDC. No long-lived AWS keys anywhere. The trust policy pins GitHub's immutable owner/repo IDs, so a renamed or recreated repo cannot deploy:

// GitHub embeds immutable owner/repo IDs in the OIDC sub claim (verified via
// CloudTrail); pinning them means a renamed/recreated repo cannot deploy.
const GITHUB_SUB = 'repo:julianwalder@…/cruiser-aws@…:*';

Staging auto-deploys on merge to main; prod is workflow_dispatch only - "prod deploys are deliberate." Secrets are never in CloudFormation: the SecretsStack creates CHANGEME shells, values set out-of-band.

Cognito. Benefit: managed OIDC, the migration Lambda, SES-branded emails, and no password infrastructure to own. Trades encountered en route (all fixed, all good war stories): the ECS task role initially had no cognito-idp permissions so "Invite user" silently never worked; NEW_PASSWORD_REQUIRED had to be handled explicitly; deleting a member had to also delete the Cognito account or they could still authenticate.

Billing: one seam, two countries, two providers

The school operates two legal entities, and the platform invoices under both:

  • Cruiser Aviation GmbH (Austria) - Stripe Invoicing, EUR, 20% USt.
  • Cruiser Aviation SRL (Romania) - SmartBill (the RO fiscal provider), 21% TVA, documents printed in RON.

Everything hides behind one interface (app/src/lib/billing/provider.ts):

export interface InvoiceProvider {
  readonly name: ProviderName;
  issueInvoice(input, ctx?): Promise<IssuedDocumentRef>;
  convertProformaToFiscal(input, ctx?): Promise<IssuedDocumentRef>;
  fetchInvoicePdf(kind, series, number): Promise<Uint8Array>;
  listInvoices(filter): Promise<ProviderInvoiceSummary[]>;
  cancelInvoice(kind, ref, reason): Promise<void>;
  getStatus(): Promise<ProviderStatus>;
}

Three implementations: SmartBillProvider, StripeInvoiceProvider, and a MockProvider that is the default - "staging must never reach a real provider, because they issue real fiscal documents" (SmartBill has no test mode). Providers are loaded with dynamic import() so the unselected SDK never enters the bundle, and a document is always operated on by the provider that issued it, pinned per row - the env selection only governs new self-service purchases.

Several details make this worth examining closely:

  • The provider numbers documents, never the app. The mock still needs numbers, so it does transaction-scoped MAX(number)+1 backed by the unique (series, number) constraint - a concurrent insert loses the race with a 23505 and the service retries. No counter table.
  • One settle path. settleProforma is the single paid→fiscal→ledger pipeline; the Stripe webhook and the admin "mark paid" button are just its two entry points. Idempotent (already-PAID short-circuits; SELECT … FOR UPDATE serializes; webhook event IDs are recorded and checked). Stripe models the whole lifecycle on one document (open = proforma → paid = fiscal), so its "conversion" is a no-op returning the same ref - the same row transitions in place, keeping recon 1:1 with fiscal documents.
  • FX pinning as a correctness rule. SRL invoices are recorded in RON exactly as SmartBill prints them: the official BNR EUR→RON rate is fetched before issue, pinned as exchangeRate, converted with SmartBill's per-unit rounding so app amounts equal the fiscal document to the cent - and the app refuses to issue if bnr.ro is down rather than let stored amounts drift. SmartBill doesn't print the rate in reference-price mode, so the app prints it in the document's Mențiuni field itself.
  • The external call sits inside the DB transaction on purpose: "a failed insert can't follow a successful external issue silently (the tx aborts loudly instead)."
  • VAT is dated config, not an inline constant - a VAT_SCHEDULE array keyed by effective date, ready for the next rate change.
  • Historical invoices were recovered by parsing SmartBill PDFs with pdftotext -layout (their web exports carry no line items) plus e-Factura UBL XML, with three-tier client matching (email → CNP → company CUI) and a human-in-the-loop CSV for the unmatched tail.

The FIFO hours ledger

The economic core of a flight school: members buy hour packages; flying consumes them oldest-first. The engine (app/src/app/(app)/usage/fifo.ts, 317 LOC) makes three choices worth writing about:

  1. Pure and deterministic - no imports, no I/O, no Date.now() (callers inject today). Nothing persisted: balances are recomputed on read over ~8k flights, which is fast and always consistent.
  2. Integer hundredths - all hour arithmetic in integer cents, because floating-point drift across ~8,000 allocations would corrupt the cent-exact reconciliation.
  3. One implementation, many consumers - the /usage screens, the dashboard, the WhatsApp bot (bundled into the Lambda), and the cutover reconciliation script all call the same computeUsage. A unit test asserts the bot's payload equals the engine's output, so "the bot's numbers can never diverge from the /usage screens."

The deduction rule reads like the spec:

const payer = flight.pendingValidation ? flight.pilotId : (flight.payerId ?? flight.pilotId);
const charged =
  payer === userId &&
  flight.instructorId !== userId &&   // instructors never pay for instructing
  !isExempt;                          // FERRY / DEMO / PROMO are free

Overdraw doesn't error - the last package absorbs it and goes negative, surfacing as an overdrawn status in the UI.

Engineering patterns worth a section of their own

  • "One write path" as an explicit rule, five times over: billing settle, scheduling availability (console and WhatsApp both commit through commitAvailability()), FIFO, flight eligibility, payment links. Each file says so in its header.
  • Defense in depth, labelled: proxy route table → page-level session check → action-level requireRole(). Comments literally say "Defense in depth: the proxy already guards these routes." The public-route allowlist documents the trust boundary of every entry inline (app/src/proxy.ts:10-23) - Stripe signature, x-wa-secret, unguessable UUID, single-use invite code, x-cron-secret.
  • Every integration mirrors one template - env creds with a CHANGEME gate, typed disabled-errors, lazy singleton, fixed timeout, retry only on 5xx/429/timeout, safe user message + server-only detail. Three files cross-reference each other as mirrors. Payoff: npm run build and npm run dev work with zero AWS configured.
  • Idempotency by dedupe-key claim: every scheduled send claims an INSERT … ON CONFLICT DO NOTHING key first, so the 15-min tick "can re-run, fire late, or crash mid-batch without double-messaging anyone."
  • Raw Cognito/provider errors never leak - SDK exceptions are mapped to typed codes at the module boundary ("UserNotFoundException: indistinguishable from bad password on purpose").
  • Behavioral changes are recorded at the decision site - e.g. the FIFO engine carries both rule sets with a comment pointing at DECISIONS.md #1.
  • Small correctness gems: the /pay/[id] Stripe session is minted on button press so WhatsApp link previews cost nothing; "no existence oracle" on other users' invoices; search is diacritic-insensitive end-to-end (unaccent + Unicode folding - "hadean" finds "Hădean"); iOS auto-zoom killed by 16px form controls; Hobbs fields accept comma decimals.

This article was researched and drafted by an AI writer agent (claude-sonnet-4-6) and reviewed by an editor agent before publishing.

Ask about Julian Walder

Grounded in his real work

Hi! I'm Julian Walder's assistant. Ask me anything about his work, projects, or background in AI.