English · Türkçe
A Windows desktop application that turns car-rental agreements into legally valid Turkish e-invoices. It pulls billable rental records from the Sixt Dealer API, converts the ones the operator selects into UBL-TR 1.2 documents, and issues them as e-Fatura or e-Arşiv through the Digital Planet integrator's SOAP service.
The hard requirement behind every design decision: the same rental is never invoiced twice. Duplicate invoices are not a cosmetic bug in this domain — they are a tax filing that has to be cancelled, explained, and corrected. Four independent layers prevent it, and they hold even if the machine loses power mid-send.
Built with .NET 8 · WPF · EF Core + SQLite, with a hand-written SOAP 1.1 client.
- Why it exists
- Screenshots
- How a rental becomes an invoice
- Never invoice the same rental twice
- Features
- Architecture
- Project layout
- Getting started
- Configuration
- Notes & limitations
A Sixt franchise station closes dozens of rental agreements a week. Each one has to become an invoice, and in Turkey that invoice must be an electronic document in a government-mandated format:
- The receiver decides the document type. If they are registered for e-Fatura, the invoice must be sent through the e-Fatura network; if not, it must be issued as an e-Arşiv invoice. Getting this wrong means the invoice is rejected.
- The document itself is UBL-TR 1.2 XML — a specific Turkish profile of OASIS UBL, with its own mandatory fields, tax breakdown rules and identifier schemes.
- Sealing (mali mühür) and delivery to the revenue administration (GİB) are done by a licensed integrator, not by the merchant.
Doing this by hand means copying agreement numbers, plates, dates and amounts into a portal, dozens of times a week, with a tax penalty waiting behind every typo. This application does the whole run in one pass and keeps a full audit trail of what was sent, when, and what came back.
The application UI is Turkish. Customer names, plates, company details and licence keys in these captures are demo data.
More screens
| Screen | File |
|---|---|
| Dashboard | dashboard.png |
| History — successful run | history-success-log.png |
| Settings — seller identity (UBL mandatory fields) | settings-seller.png |
| Guided tour for first-time operators | guided-tour.png |
| About / licence status | about.png |
sequenceDiagram
autonumber
actor Op as Operator
participant App as Desktop app
participant Sixt as Sixt Dealer API
participant DB as SQLite
participant DP as Digital Planet (SOAP)
Op->>App: "Fetch list"
App->>Sixt: POST /login → Bearer JWT
App->>Sixt: GET /dealer-invoice-items
Sixt-->>App: billable agreements + extras
Op->>App: select rows → "Issue selected"
loop for each agreement
App->>DB: already processed? (unique index)
App->>DP: CheckCustomerTaxId → e-Fatura payer?
App->>DP: GetNewInvoiceId → invoice no + UUID
App->>DB: persist no + UUID BEFORE sending (outbox)
App->>App: build UBL-TR 1.2 (VAT, seller, buyer, FX rate)
App->>DP: SendUBLInvoice / SendEArchiveData
DP-->>App: accepted (DP seals it and forwards to GİB)
App->>Sixt: POST /dealer-invoice-number-update
App->>DB: mark Sent
end
The operator is in control the whole way: the confirmation dialog states which mode the run is in, a live step panel shows where each record is, and Stop performs a soft cancel that finishes the record in flight rather than abandoning it half-sent.
This is the part of the system worth reading the code for. Four layers, each of which would be sufficient on a good day, arranged so that no single failure can produce a duplicate:
| # | Layer | What it stops |
|---|---|---|
| 1 | Unique index on ProcessedDocuments.SourceDocumentId |
The same agreement being inserted twice, even by two concurrent runs |
| 2 | Outbox — the invoice number and UUID are written to the database before the send call | A crash between "reserve a number" and "send". On restart the run continues with the reserved identifiers instead of drawing new ones |
| 3 | Target-side idempotency — reconciliationId = AgreementId |
A retry that reaches the integrator: Digital Planet rejects a second document carrying the same UUID/ETTN |
| 4 | Source-side marking — dealer-invoice-number-update back to Sixt |
The record reappearing in tomorrow's list at all |
State machine: New → Pending → Sent, plus Failed. Sent is terminal — nothing in the code
path can move a record out of it, so a "retry" of a sent invoice is structurally impossible rather
than merely discouraged.
Why the outbox matters more than it looks: GetNewInvoiceId has a side effect at the integrator —
it consumes an invoice number from the seller's official series. Asking for a second number because
the first one was lost in a crash produces a gap in a numbered series that the tax authority
expects to be contiguous. Writing it down before use is what keeps the series clean.
Invoice run
- Filter billable agreements by pickup/return date, station and invoice type before fetching
- Per-row selection with a detail panel: agreement, vehicle, period, extras and computed total
- UBL preview — read the generated XML before committing to anything
- Live step panel per record, plus a soft Stop that never leaves a half-sent document
- Automatic routing:
CheckCustomerTaxIddecides e-Fatura vs e-Arşiv per receiver
Correctness
- UBL-TR 1.2 generation: VAT breakdown, VAT-inclusive price handling, seller/buyer parties, person vs. company identification (TCKN/VKN), rental line plus per-extra lines
- TCMB daily rate for foreign-currency invoices, with a manual override
- Turkish tax-number validation and party-type detection
Operations
- Test mode (dry-run) — fetches, builds UBL and writes to the database but issues nothing; an amber banner makes the mode impossible to miss
- History screen with filters, per-record step log, PDF archive and Excel export
- Dashboard: today's issue count, failures, monthly totals, 7-day trend, live FX rates
- Guided tour for first-time operators
- File logging via Serilog; SQLite means no database server to install on the operator's PC
Licensing
- Machine-bound licence verified against a panel, with an offline grace window
- Signature verification is ECDSA P-256 with an embedded public key; the vendor-side panel that issues these licences is a separate project
WPF (App.Desktop) ── Material Design, MVVM, LiveCharts
│
▼
App.Application ── InvoiceProcessor / InvoiceBatchRunner
│ dashboard + history queries, TCMB rates
┌─────────┼─────────┐
▼ ▼ ▼
App.Sixt App.Data App.DigitalPlanet
(HTTP) (EF Core) (hand-written SOAP 1.1)
└─────────┼─────────┘
▼
App.Core ── models, interfaces, UBL builder, options
App.Core depends on nothing; everything depends on App.Core. There is no server component — the
engine lives in App.Application and is driven by the desktop UI.
| Layer | Choice |
|---|---|
| Runtime | .NET 8, net8.0-windows |
| UI | WPF, MaterialDesignThemes, CommunityToolkit.Mvvm, LiveChartsCore, WebView2 |
| Data | EF Core 8 + SQLite — a single file, no server to install |
| Sixt | HttpClient + Bearer JWT |
| Digital Planet | Hand-written SOAP 1.1 client over plain text/xml |
| Export | ClosedXML (Excel), PDF archive from the integrator |
| Hosting | Generic Host + DI, Serilog file logging |
Why a hand-written SOAP client. The generated WCF client wanted MTOM; the integrator's
MTOM-enabled endpoint answers a plain text/xml body with 415 Unsupported Media Type. Rather than
fight the generated stack, the client is about 200 lines that build the envelope directly and post
it to the non-MTOM endpoint. Two details cost real debugging time and are now pinned by tests:
the endpoint must be the withoutmtom host, and the credential order is CorporateCode then
LoginName — reversed, the service returns an empty ticket instead of an error.
Tests. 202 tests across two projects, concentrated on the invariants that matter: deduplication and idempotency, UBL generation, tax-number and party-type rules, currency conversion, and the desktop view-models.
dotnet test
src/
├── App.Core models, interfaces, UBL-TR builder, options, TrClock
├── App.Data EF Core DbContext, entities, migrations
├── App.Sixt Sixt Dealer API client (login, items, number-update)
├── App.DigitalPlanet SOAP 1.1 client (ticket, tax-id check, invoice id, send, status, PDF)
├── App.Application invoicing engine, dashboard/history queries, TCMB rate provider
└── App.Desktop WPF UI: Dashboard · Issue Invoices · History · Settings
tests/
├── App.Tests dedupe, idempotency, UBL, tax-id, party type, currency
└── App.Desktop.Tests view-model and desktop service tests
Prerequisites: Windows, .NET 8 SDK.
git clone <this repo>
cd einvoice-automation
dotnet run --project src/App.DesktopThe database is created and migrated on first start — it is a single SQLite file next to the executable, so there is nothing to install.
To actually issue invoices you need credentials that only the two vendors can give you:
- Sixt Dealer API — dealer e-mail and password, provided by Sixt to franchise partners.
- Digital Planet — corporate code, login name and password for the integration service.
- Seller identity — your own VKN, title, tax office and address. These are UBL-mandatory; the app refuses to issue an invoice while VKN or title is empty.
Fill them in from the Settings screen (each section has its own test connection button) or in
src/App.Desktop/appsettings.json. The committed file contains placeholders only.
Start in test mode. Sync:SendEnabled = false runs the full pipeline — fetch, dedupe check,
UBL generation, database writes — and stops short of issuing anything. Confirm the generated UBL
looks right for your seller profile before turning it on.
# publish a single self-contained folder
dotnet publish src/App.Desktop -c Release -r win-x64 --self-contained| Section | Key | Meaning |
|---|---|---|
ConnectionStrings |
AppDb |
SQLite file, e.g. Data Source=sixt-fatura.db |
Sixt |
BaseUrl, Email, Password, PageSize |
Dealer API endpoint and credentials |
DigitalPlanet |
ServiceUrl, CorporateCode, LoginName, Password |
Integrator service — must be the non-MTOM URL |
Seller |
Vkn, Title, TaxOffice, City, District, StreetAddress, … |
Seller party written into every UBL document |
Invoice |
DefaultKdvRate, PricesIncludeVat, PaymentMeansCode, AutoEmailEArchive |
Invoice defaults; payment means is mandatory for GİB e-Arşiv reporting |
ExchangeRates |
UseLiveRates, Manual.USD, Manual.EUR |
TCMB live rates, or fixed values |
Sync |
SendEnabled, AutoRouteInvoiceKind |
Dry-run switch and automatic e-Fatura/e-Arşiv routing |
License |
PanelUrl, Key |
Licence panel address and key |
Archive |
PdfPath |
Where issued invoice PDFs are stored |
Settings saved from the UI take effect on the next run — no restart required.
- Windows only. It is a WPF application; the engine underneath is plain .NET, but the UI is not portable.
- It cannot run standalone. Without Sixt dealer credentials and a Digital Planet integrator account there is nothing to fetch and nowhere to send. This repository is published as a portfolio and reference implementation, not as a product you can deploy against your own data.
- Vendor documentation is deliberately not included. The Sixt Dealer API specification and the Digital Planet integration manual belong to those companies. What is described here is this project's own integration, written from scratch.
- Sealing and GİB delivery are not done here. The application produces UBL and hands it to the integrator, which applies the financial seal and forwards the document. Content-level GİB schematron validation is an ongoing area.
- The licence check is client-side. Anyone who can modify the binary can remove it — true of any offline licensing scheme. What the signature does guarantee is that a licence cannot be forged.
- No automated UI tests. The invariants are covered by unit tests; the WPF surface is covered only through view-model tests.
MIT © Furkan Paşaoğlu
Screenshots and sample data in this repository are fabricated for demonstration and do not represent any real customer, vehicle or company.




