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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Comfortable with Swift, SwiftUI, and basic data modeling
- PDFKit is simulator-safe, but always validate PDF output on a real device before shipping
- StoreKit 2 knowledge helpful — required for the subscription monetization step
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
- PDF text clipping on long descriptions. PDFKit's
String.draw(at:)does not wrap text. For descriptions longer than ~45 characters, switch toNSAttributedString.draw(in:)with a bounded rect, or truncate explicitly. - SwiftData cascade delete orphans line items. If you delete an
Invoicewithout settingdeleteRule: .cascadeon thelineItemsrelationship, line items pile up invisibly in your store. Always verify cascade rules in the model. - UIGraphicsPDFRenderer blocks the main thread. For invoices with many line items, PDF generation can take 200–400 ms. Wrap the
PDFGenerator.generatecall in aTask { await MainActor.run { … } }or dispatch to a background actor — never call it synchronously inbody. - App Store rejection: missing export compliance. If your app never uses encryption beyond what iOS provides, add
ITSAppUsesNonExemptEncryption = NOto yourInfo.plist. Missing this causes a standard rejection on first submission. - ShareLink with raw Data requires a filename. Sharing
Datadirectly viaShareLinkwithout a customTransferabletype causes some receiving apps (Mail, Files) to receive a file named "Unknown". Wrap your PDF data in aTransferablestruct that provides a suggested filename.
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.