```html How to Build an Invoice Generator App in SwiftUI (2026)

How to Build an Invoice Generator App in SwiftUI

An invoice generator app lets freelancers and small business owners create, send, and track professional PDF invoices directly from their iPhone. If your users are tired of spreadsheet hacks and overpriced SaaS, this is the app they'll pay for.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses SwiftData as its local data layer, storing Invoice, Client, and LineItem models with one-to-many relationships. SwiftUI Forms handle all data entry, feeding directly into SwiftData via @Environment(\.modelContext). A dedicated PDFGenerator service converts model data into a PDFDocument using PDFKit's drawing APIs — no third-party dependencies. PDF sharing is handled natively by SwiftUI's ShareLink. StoreKit 2 manages the subscription paywall, with entitlement checks gating PDF export for free-tier users.

InvoiceGenerator/
├── App/
│   └── InvoiceGeneratorApp.swift   # ModelContainer setup
├── Models/
│   ├── Invoice.swift               # @Model, status enum, computed total
│   ├── Client.swift                # @Model, name/email/address
│   └── LineItem.swift              # @Model, description/qty/rate
├── Views/
│   ├── InvoiceListView.swift       # @Query-driven list
│   ├── InvoiceDetailView.swift     # Read + share PDF button
│   ├── NewInvoiceView.swift        # Multi-section Form
│   └── PDFPreviewView.swift        # PDFView wrapper (UIViewRepresentable)
├── Services/
│   └── PDFGenerator.swift          # PDFKit drawing logic
├── Store/
│   └── SubscriptionStore.swift     # StoreKit 2 purchase flow
└── PrivacyInfo.xcprivacy           # Required for App Store

Step-by-step

1. Project setup

Create a new Xcode project using the iOS App template, name it InvoiceGenerator, select SwiftUI as the interface, and check "Use SwiftData" in the options. Then wire up the ModelContainer with all three models in the app entry point.

// App/InvoiceGeneratorApp.swift
import SwiftUI
import SwiftData

@main
struct InvoiceGeneratorApp: App {
    var body: some Scene {
        WindowGroup {
            InvoiceListView()
        }
        .modelContainer(for: [Invoice.self, Client.self, LineItem.self])
    }
}

2. Data models

Define your three @Model classes. The Invoice model carries a computed total so your views always show a consistent sum without extra queries.

// Models/Invoice.swift
import SwiftData
import Foundation

enum InvoiceStatus: String, Codable, CaseIterable {
    case draft, sent, paid, overdue
}

@Model
final class Client {
    var name: String
    var email: String
    var address: String
    @Relationship(deleteRule: .cascade) var invoices: [Invoice] = []

    init(name: String, email: String, address: String) {
        self.name = name; self.email = email; self.address = address
    }
}

@Model
final class LineItem {
    var itemDescription: String
    var quantity: Double
    var rate: Double
    var invoice: Invoice?

    var subtotal: Double { quantity * rate }

    init(itemDescription: String, quantity: Double, rate: Double) {
        self.itemDescription = itemDescription
        self.quantity = quantity
        self.rate = rate
    }
}

@Model
final class Invoice {
    var number: String
    var issueDate: Date
    var dueDate: Date
    var status: InvoiceStatus
    var notes: String
    var client: Client?
    @Relationship(deleteRule: .cascade) var lineItems: [LineItem] = []

    var total: Double { lineItems.reduce(0) { $0 + $1.subtotal } }

    init(number: String, issueDate: Date = .now,
         dueDate: Date, notes: String = "") {
        self.number = number
        self.issueDate = issueDate
        self.dueDate = dueDate
        self.status = .draft
        self.notes = notes
    }
}

3. Invoice list UI

Use @Query to drive the list — SwiftData keeps it automatically up to date. Add a status badge with a color dot so users can triage at a glance.

// Views/InvoiceListView.swift
import SwiftUI
import SwiftData

struct InvoiceListView: View {
    @Query(sort: \Invoice.issueDate, order: .reverse) private var invoices: [Invoice]
    @Environment(\.modelContext) private var context
    @State private var showingNewInvoice = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(invoices) { invoice in
                    NavigationLink(value: invoice) {
                        InvoiceRowView(invoice: invoice)
                    }
                }
                .onDelete(perform: deleteInvoices)
            }
            .navigationTitle("Invoices")
            .navigationDestination(for: Invoice.self) { invoice in
                InvoiceDetailView(invoice: invoice)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("New Invoice", systemImage: "plus") {
                        showingNewInvoice = true
                    }
                }
            }
            .sheet(isPresented: $showingNewInvoice) {
                NewInvoiceView()
            }
        }
    }

    private func deleteInvoices(at offsets: IndexSet) {
        for index in offsets { context.delete(invoices[index]) }
    }
}

struct InvoiceRowView: View {
    let invoice: Invoice
    private let formatter = { () -> NumberFormatter in
        let f = NumberFormatter(); f.numberStyle = .currency; return f
    }()

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(invoice.number).font(.headline)
                Text(invoice.client?.name ?? "No Client")
                    .font(.subheadline).foregroundStyle(.secondary)
            }
            Spacer()
            VStack(alignment: .trailing, spacing: 2) {
                Text(formatter.string(from: invoice.total as NSNumber) ?? "$0.00")
                    .font(.headline)
                StatusBadge(status: invoice.status)
            }
        }
        .padding(.vertical, 4)
    }
}

struct StatusBadge: View {
    let status: InvoiceStatus
    var body: some View {
        Text(status.rawValue.capitalized)
            .font(.caption2).bold()
            .padding(.horizontal, 6).padding(.vertical, 2)
            .background(color.opacity(0.15), in: Capsule())
            .foregroundStyle(color)
    }
    private var color: Color {
        switch status {
        case .draft: .gray
        case .sent: .blue
        case .paid: .green
        case .overdue: .red
        }
    }
}

#Preview {
    InvoiceListView()
        .modelContainer(for: [Invoice.self, Client.self, LineItem.self],
                        inMemory: true)
}

4. PDF invoice generation with PDFKit

This is the core feature. PDFGenerator uses a UIGraphicsPDFRenderer to draw text, lines, and layout directly — no HTML-to-PDF hacks, no dependencies, just reliable vector output that looks professional at any scale.

// Services/PDFGenerator.swift
import PDFKit
import UIKit

struct PDFGenerator {
    static func generate(for invoice: Invoice) -> Data {
        let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
        let renderer = UIGraphicsPDFRenderer(bounds: pageRect)

        return renderer.pdfData { ctx in
            ctx.beginPage()
            let context = ctx.cgContext

            // — Header —
            drawText("INVOICE", at: CGPoint(x: 40, y: 40),
                     font: .boldSystemFont(ofSize: 28), color: .black)
            drawText(invoice.number, at: CGPoint(x: 40, y: 74),
                     font: .systemFont(ofSize: 14), color: .gray)

            // — Bill To —
            let client = invoice.client
            drawText("Bill To:", at: CGPoint(x: 40, y: 130),
                     font: .boldSystemFont(ofSize: 12), color: .darkGray)
            drawText(client?.name ?? "—", at: CGPoint(x: 40, y: 148),
                     font: .systemFont(ofSize: 12), color: .black)
            drawText(client?.email ?? "", at: CGPoint(x: 40, y: 164),
                     font: .systemFont(ofSize: 11), color: .gray)
            drawText(client?.address ?? "", at: CGPoint(x: 40, y: 180),
                     font: .systemFont(ofSize: 11), color: .gray)

            // — Dates —
            let df = DateFormatter(); df.dateStyle = .medium
            drawText("Issue: \(df.string(from: invoice.issueDate))",
                     at: CGPoint(x: 380, y: 148),
                     font: .systemFont(ofSize: 11), color: .gray)
            drawText("Due:   \(df.string(from: invoice.dueDate))",
                     at: CGPoint(x: 380, y: 164),
                     font: .systemFont(ofSize: 11), color: .gray)

            // — Table header line —
            context.setStrokeColor(UIColor.systemGray4.cgColor)
            context.setLineWidth(1)
            context.move(to: CGPoint(x: 40, y: 220))
            context.addLine(to: CGPoint(x: 572, y: 220))
            context.strokePath()

            drawText("Description", at: CGPoint(x: 40, y: 228),
                     font: .boldSystemFont(ofSize: 11), color: .darkGray)
            drawText("Qty", at: CGPoint(x: 360, y: 228),
                     font: .boldSystemFont(ofSize: 11), color: .darkGray)
            drawText("Rate", at: CGPoint(x: 420, y: 228),
                     font: .boldSystemFont(ofSize: 11), color: .darkGray)
            drawText("Amount", at: CGPoint(x: 500, y: 228),
                     font: .boldSystemFont(ofSize: 11), color: .darkGray)

            // — Line items —
            let cf = NumberFormatter(); cf.numberStyle = .currency
            var y: CGFloat = 250
            for item in invoice.lineItems {
                drawText(item.itemDescription, at: CGPoint(x: 40, y: y),
                         font: .systemFont(ofSize: 11), color: .black)
                drawText(String(format: "%.0f", item.quantity),
                         at: CGPoint(x: 360, y: y),
                         font: .systemFont(ofSize: 11), color: .black)
                drawText(cf.string(from: item.rate as NSNumber) ?? "",
                         at: CGPoint(x: 420, y: y),
                         font: .systemFont(ofSize: 11), color: .black)
                drawText(cf.string(from: item.subtotal as NSNumber) ?? "",
                         at: CGPoint(x: 500, y: y),
                         font: .systemFont(ofSize: 11), color: .black)
                y += 20
            }

            // — Total —
            context.move(to: CGPoint(x: 460, y: y + 6))
            context.addLine(to: CGPoint(x: 572, y: y + 6))
            context.strokePath()
            drawText("TOTAL", at: CGPoint(x: 400, y: y + 14),
                     font: .boldSystemFont(ofSize: 13), color: .black)
            drawText(cf.string(from: invoice.total as NSNumber) ?? "",
                     at: CGPoint(x: 500, y: y + 14),
                     font: .boldSystemFont(ofSize: 13), color: .black)

            // — Notes —
            if !invoice.notes.isEmpty {
                drawText("Notes: \(invoice.notes)",
                         at: CGPoint(x: 40, y: y + 60),
                         font: .italicSystemFont(ofSize: 10), color: .gray)
            }
        }
    }

    private static func drawText(_ text: String, at point: CGPoint,
                                  font: UIFont, color: UIColor) {
        let attrs: [NSAttributedString.Key: Any] = [
            .font: font, .foregroundColor: color
        ]
        text.draw(at: point, withAttributes: attrs)
    }
}

5. New invoice form

A multi-section SwiftUI Form handles client details, invoice metadata, and dynamic line-item entry. The total updates live as the user types quantities and rates.

// Views/NewInvoiceView.swift
import SwiftUI
import SwiftData

struct NewInvoiceView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss

    @State private var invoiceNumber = "INV-001"
    @State private var clientName = ""
    @State private var clientEmail = ""
    @State private var clientAddress = ""
    @State private var dueDate = Calendar.current.date(byAdding: .day, value: 30, to: .now) ?? .now
    @State private var notes = ""
    @State private var lineItems: [(desc: String, qty: Double, rate: Double)] = [
        ("", 1, 0)
    ]

    private var total: Double {
        lineItems.reduce(0) { $0 + ($1.qty * $1.rate) }
    }

    var body: some View {
        NavigationStack {
            Form {
                Section("Invoice") {
                    TextField("Invoice Number", text: $invoiceNumber)
                    DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
                }

                Section("Client") {
                    TextField("Full Name", text: $clientName)
                    TextField("Email", text: $clientEmail)
                        .keyboardType(.emailAddress)
                        .autocorrectionDisabled()
                    TextField("Address", text: $clientAddress, axis: .vertical)
                        .lineLimit(2...4)
                }

                Section("Line Items") {
                    ForEach(lineItems.indices, id: \.self) { i in
                        VStack(alignment: .leading, spacing: 8) {
                            TextField("Description", text: $lineItems[i].desc)
                            HStack {
                                LabeledContent("Qty") {
                                    TextField("1", value: $lineItems[i].qty, format: .number)
                                        .keyboardType(.decimalPad)
                                        .multilineTextAlignment(.trailing)
                                }
                                LabeledContent("Rate") {
                                    TextField("0.00", value: $lineItems[i].rate, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
                                        .keyboardType(.decimalPad)
                                        .multilineTextAlignment(.trailing)
                                }
                            }
                        }
                        .padding(.vertical, 4)
                    }
                    .onDelete { lineItems.remove(atOffsets: $0) }

                    Button("Add Line Item") {
                        lineItems.append(("", 1, 0))
                    }
                }

                Section {
                    LabeledContent("Total") {
                        Text(total, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
                            .bold()
                    }
                }

                Section("Notes") {
                    TextField("Optional notes or payment terms", text: $notes, axis: .vertical)
                        .lineLimit(3...6)
                }
            }
            .navigationTitle("New Invoice")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                        .disabled(invoiceNumber.isEmpty || clientName.isEmpty)
                }
            }
        }
    }

    private func save() {
        let client = Client(name: clientName, email: clientEmail, address: clientAddress)
        let invoice = Invoice(number: invoiceNumber, dueDate: dueDate, notes: notes)
        invoice.client = client
        for item in lineItems where !item.desc.isEmpty {
            let li = LineItem(itemDescription: item.desc, quantity: item.qty, rate: item.rate)
            li.invoice = invoice
            invoice.lineItems.append(li)
        }
        context.insert(client)
        context.insert(invoice)
        dismiss()
    }
}

#Preview {
    NewInvoiceView()
        .modelContainer(for: [Invoice.self, Client.self, LineItem.self],
                        inMemory: true)
}

6. Invoice detail, PDF preview, and sharing

The detail view wraps PDFKit's PDFView in a UIViewRepresentable and uses SwiftUI's native ShareLink to let users AirDrop, email, or save the PDF — zero custom sharing code required.

// Views/InvoiceDetailView.swift
import SwiftUI
import PDFKit

struct PDFViewRepresentable: UIViewRepresentable {
    let data: Data
    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.autoScales = true
        view.displayMode = .singlePageContinuous
        return view
    }
    func updateUIView(_ uiView: PDFView, context: Context) {
        uiView.document = PDFDocument(data: data)
    }
}

struct InvoiceDetailView: View {
    @Bindable var invoice: Invoice
    @State private var pdfData: Data?

    var body: some View {
        Group {
            if let pdfData {
                PDFViewRepresentable(data: pdfData)
            } else {
                ContentUnavailableView("Generating PDF…",
                    systemImage: "doc.richtext")
            }
        }
        .navigationTitle(invoice.number)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            if let pdfData {
                ToolbarItem(placement: .primaryAction) {
                    ShareLink(
                        item: pdfData,
                        preview: SharePreview(
                            "\(invoice.number).pdf",
                            image: Image(systemName: "doc.fill")
                        )
                    )
                }
            }
            ToolbarItem(placement: .secondaryAction) {
                Picker("Status", selection: $invoice.status) {
                    ForEach(InvoiceStatus.allCases, id: \.self) { s in
                        Text(s.rawValue.capitalized).tag(s)
                    }
                }
            }
        }
        .task {
            pdfData = PDFGenerator.generate(for: invoice)
        }
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Invoice.self, Client.self, LineItem.self,
                                        configurations: config)
    let client = Client(name: "Acme Corp", email: "pay@acme.com", address: "1 Acme Rd")
    let invoice = Invoice(number: "INV-042",
                          dueDate: Calendar.current.date(byAdding: .day, value: 30, to: .now)!)
    invoice.client = client
    invoice.lineItems = [LineItem(itemDescription: "Design work", quantity: 8, rate: 150)]
    container.mainContext.insert(client)
    container.mainContext.insert(invoice)
    return NavigationStack {
        InvoiceDetailView(invoice: invoice)
    }.modelContainer(container)
}

7. Privacy Manifest

Since iOS 17.4, App Store Connect rejects apps that use certain APIs without a Privacy Manifest. Add PrivacyInfo.xcprivacy to your app target — this is not optional and reviewers will flag its absence.

<!-- PrivacyInfo.xcprivacy (add to app target, not a framework) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <!-- UserDefaults — used by SwiftData and system frameworks -->
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
    <dict>
      <!-- File timestamp APIs — used when writing PDF to disk -->
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2 to implement a subscription paywall — gate PDF export and unlimited invoices behind a monthly or annual plan. Define your subscription products in App Store Connect (e.g., com.yourapp.pro.monthly), then use Product.products(for:) to fetch them at runtime. Listen for transaction updates with Transaction.updates in an async Task launched from your app entry point, and persist the entitlement state in an @Observable SubscriptionStore class. Gate the PDF export button and invoice creation limit behind a check on subscriptionStore.isSubscribed. For free users, show a paywall sheet using .sheet(isPresented:) with a SubscriptionStoreView (available in iOS 17+) — Apple's pre-built paywall UI handles purchase, restore, and error states out of the box, significantly cutting your StoreKit integration work.

Shipping this faster with Soarias

Soarias automates the parts of this guide that have nothing to do with your app's actual value: it scaffolds the SwiftData model layer and project file structure in seconds, generates the PrivacyInfo.xcprivacy based on detected API usage, sets up fastlane lanes for TestFlight distribution and App Store metadata, and handles the App Store Connect submission flow including screenshot framing. For an intermediate project like this invoice generator, that's roughly steps 1, 7, and all the fastlane/ASC ceremony taken entirely off your plate.

For an intermediate app at one week of estimated build time, Soarias typically compresses the non-feature work (project plumbing, privacy manifest, submission pipeline) to under an hour. That means you spend your week on the PDFKit rendering quality, your subscription paywall UX, and polishing the invoice form — the things that will actually earn you five-star reviews — instead of debugging xcodebuild archive flags at midnight before a submission deadline.

Related guides

FAQ

Does this work on iOS 16?

No. This guide uses SwiftData (@Model, @Query, ModelContainer) which requires iOS 17+. If you need iOS 16 support, replace SwiftData with Core Data and use @FetchRequest instead of @Query. PDFKit and PDFView work on iOS 16 without changes.

Do I need a paid Apple Developer account to test?

Not for basic simulator testing — you can run the app and generate PDFs entirely in Xcode's iOS Simulator at no cost. However, you do need the $99/year Apple Developer Program membership to test StoreKit subscriptions on a real device via TestFlight, and to submit to the App Store.

How do I add this to the App Store?

Archive the app in Xcode (Product → Archive), upload via Xcode Organizer or xcrun altool/fastlane, then complete the App Store Connect listing: add screenshots for at least iPhone 6.9" and 6.5" sizes, fill in the description and keywords, declare your subscription pricing, and submit for review. Expect a 1–3 day review time for a first submission.

How do I handle multiple currencies in the PDF?

Add a currencyCode: String property to your Invoice model, defaulting to Locale.current.currency?.identifier ?? "USD". Pass this to NumberFormatter in PDFGenerator and to SwiftUI's .currency(code:) format style in the form. This is the correct intermediate-level extension once the core flow is working — do it after your first TestFlight build, not before.

Last reviewed: 2026-05-12 by the Soarias team.

```