diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d03cdc5..1eb0c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Test - GoExpress.Shipping run: dotnet test tests/Parcel.NET.GoExpress.Shipping.Tests/Parcel.NET.GoExpress.Shipping.Tests.csproj -c Release --no-build -f ${{ matrix.target-framework }} + - name: Test - LetterXpress.Letters + run: dotnet test tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj -c Release --no-build -f ${{ matrix.target-framework }} + - name: Check for vulnerable packages if: matrix.target-framework == 'net10.0' run: | @@ -117,3 +120,4 @@ jobs: check_changes "src/Parcel.NET.Abstractions/" "abstractions" "Parcel.NET.Abstractions" check_changes "src/Parcel.NET.Dhl/" "dhl" "Parcel.NET.Dhl" check_changes "src/Parcel.NET.GoExpress/" "goexpress" "Parcel.NET.GoExpress" + check_changes "src/Parcel.NET.LetterXpress/" "letterxpress" "Parcel.NET.LetterXpress" diff --git a/.github/workflows/create-release-tag.yml b/.github/workflows/create-release-tag.yml index d4ccfd5..336a7de 100644 --- a/.github/workflows/create-release-tag.yml +++ b/.github/workflows/create-release-tag.yml @@ -20,8 +20,11 @@ on: - goexpress - goexpress-shipping - goexpress-tracking + - letterxpress + - letterxpress-letters - dhl-all - goexpress-all + - letterxpress-all - all version: description: 'SemVer version (without v prefix, e.g. 1.0.0)' diff --git a/.github/workflows/release-letterxpress-all.yml b/.github/workflows/release-letterxpress-all.yml new file mode 100644 index 0000000..c63e0b6 --- /dev/null +++ b/.github/workflows/release-letterxpress-all.yml @@ -0,0 +1,19 @@ +name: Release Parcel.NET.LetterXpress.All + +on: + push: + tags: ['letterxpress-all/v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + uses: ./.github/workflows/release-package.yml + with: + package: 'letterxpress-all' + csproj-path: 'src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj' + title-prefix: 'Parcel.NET.LetterXpress.All' + secrets: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/release-letterxpress-letters.yml b/.github/workflows/release-letterxpress-letters.yml new file mode 100644 index 0000000..13a1506 --- /dev/null +++ b/.github/workflows/release-letterxpress-letters.yml @@ -0,0 +1,19 @@ +name: Release Parcel.NET.LetterXpress.Letters + +on: + push: + tags: ['letterxpress-letters/v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + uses: ./.github/workflows/release-package.yml + with: + package: 'letterxpress-letters' + csproj-path: 'src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj' + title-prefix: 'Parcel.NET.LetterXpress.Letters' + secrets: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} diff --git a/.github/workflows/release-letterxpress.yml b/.github/workflows/release-letterxpress.yml new file mode 100644 index 0000000..f237814 --- /dev/null +++ b/.github/workflows/release-letterxpress.yml @@ -0,0 +1,19 @@ +name: Release Parcel.NET.LetterXpress + +on: + push: + tags: ['letterxpress/v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + uses: ./.github/workflows/release-package.yml + with: + package: 'letterxpress' + csproj-path: 'src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj' + title-prefix: 'Parcel.NET.LetterXpress' + secrets: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} diff --git a/Parcel.NET.slnx b/Parcel.NET.slnx index 733ad1e..1b5d7c7 100644 --- a/Parcel.NET.slnx +++ b/Parcel.NET.slnx @@ -1,37 +1,41 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 4fb3f4a..897490d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Parcel.NET -.NET libraries for logistics carrier APIs. Currently supports **DHL** (Shipping, Tracking, Pickup, Returns, Internetmarke, Location Finder) and **GO! Express** (Shipping, Tracking) — all targeting .NET 8, 9, and 10. +.NET libraries for logistics carrier APIs. Currently supports **DHL** (Shipping, Tracking, Pickup, Returns, Internetmarke, Location Finder), **GO! Express** (Shipping, Tracking), and **LetterXpress** (Letters / SMART@MAIL) — all targeting .NET 8, 9, and 10. [![CI](https://github.com/emuuu/Parcel.NET/actions/workflows/ci.yml/badge.svg)](https://github.com/emuuu/Parcel.NET/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) @@ -21,9 +21,10 @@ - DHL Unified Location Finder v1 — search by address, geo-coordinates, or keyword - GO! Express Shipping — create, cancel shipments, label generation - GO! Express Tracking — track shipments +- LetterXpress API v3 — print jobs, SMART@MAIL e-mail jobs, balance, price, transactions, invoices - Builder-pattern DI registration with typed `HttpClient` pipeline - OAuth ROPC, API key, and Basic Auth — handled automatically per carrier -- Sandbox support for DHL and GO! Express +- Sandbox / test mode support for DHL, GO! Express, and LetterXpress ## Packages @@ -43,15 +44,19 @@ | `Parcel.NET.GoExpress` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.GoExpress.svg)](https://www.nuget.org/packages/Parcel.NET.GoExpress) | GO! Express authentication, configuration, and DI extensions | | `Parcel.NET.GoExpress.Shipping` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.GoExpress.Shipping.svg)](https://www.nuget.org/packages/Parcel.NET.GoExpress.Shipping) | GO! Express Shipping client | | `Parcel.NET.GoExpress.Tracking` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.GoExpress.Tracking.svg)](https://www.nuget.org/packages/Parcel.NET.GoExpress.Tracking) | GO! Express Tracking client | +| **LetterXpress** | | | +| `Parcel.NET.LetterXpress` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.LetterXpress.svg)](https://www.nuget.org/packages/Parcel.NET.LetterXpress) | LetterXpress authentication, configuration, and DI extensions | +| `Parcel.NET.LetterXpress.Letters` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.LetterXpress.Letters.svg)](https://www.nuget.org/packages/Parcel.NET.LetterXpress.Letters) | LetterXpress API v3 client (print jobs, SMART@MAIL e-mail jobs, price, balance, transactions, invoices) | | **Meta-Packages** | | | | `Parcel.NET.Dhl.All` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.Dhl.All.svg)](https://www.nuget.org/packages/Parcel.NET.Dhl.All) | All DHL packages | | `Parcel.NET.GoExpress.All` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.GoExpress.All.svg)](https://www.nuget.org/packages/Parcel.NET.GoExpress.All) | All GO! Express packages | +| `Parcel.NET.LetterXpress.All` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.LetterXpress.All.svg)](https://www.nuget.org/packages/Parcel.NET.LetterXpress.All) | All LetterXpress packages | | `Parcel.NET.All` | [![NuGet](https://img.shields.io/nuget/v/Parcel.NET.All.svg)](https://www.nuget.org/packages/Parcel.NET.All) | All Parcel.NET packages | ## Prerequisites - .NET 8.0, 9.0, or 10.0 -- A DHL developer account and/or GO! Express credentials +- A DHL developer account, GO! Express credentials, and/or a LetterXpress account ## Installation @@ -60,10 +65,12 @@ dotnet add package Parcel.NET.Dhl.Shipping dotnet add package Parcel.NET.Dhl.Tracking dotnet add package Parcel.NET.GoExpress.Shipping +dotnet add package Parcel.NET.LetterXpress.Letters # Or install everything for a carrier dotnet add package Parcel.NET.Dhl.All dotnet add package Parcel.NET.GoExpress.All +dotnet add package Parcel.NET.LetterXpress.All # Or install all carriers at once dotnet add package Parcel.NET.All @@ -106,6 +113,41 @@ builder.Services.AddGoExpress(options => .AddGoExpressTracking(); ``` +### 2b. Register LetterXpress services + +```csharp +builder.Services.AddLetterXpress(options => +{ + options.Username = "your-username"; + options.ApiKey = "your-api-key"; + options.UseTestMode = true; // jobs go to the Postbox instead of being processed +}) +.AddLetterXpressLetters(); +``` + +Send a letter as a print job: + +```csharp +var letterXpress = serviceProvider.GetRequiredService(); + +var balance = await letterXpress.GetBalanceAsync(); + +var job = await letterXpress.CreatePrintJobAsync(new LetterRequest +{ + File = await File.ReadAllBytesAsync("invoice.pdf"), + Specification = new LetterSpecification + { + Color = LetterColor.BlackWhite, + Mode = PrintMode.Simplex, + Shipping = ShippingType.National + } +}); +``` + +> **Note:** The LetterXpress API is rate-limited to 120 requests/minute and accepts at most 50 MB per request. +> The client validates the 50 MB limit locally; throttling/retry on HTTP 429 is left to the caller's `HttpClient` +> pipeline (e.g. a `Microsoft.Extensions.Http.Resilience` handler). + ### 3. Create a shipment Use the carrier-agnostic `IShipmentService` or the carrier-specific client: diff --git a/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj b/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj index a29cbb4..d5f9985 100644 --- a/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj +++ b/docs/Parcel.NET.Docs.Generator/Parcel.NET.Docs.Generator.csproj @@ -21,6 +21,8 @@ + + diff --git a/docs/Parcel.NET.Docs.Generator/Program.cs b/docs/Parcel.NET.Docs.Generator/Program.cs index 050e250..a369899 100644 --- a/docs/Parcel.NET.Docs.Generator/Program.cs +++ b/docs/Parcel.NET.Docs.Generator/Program.cs @@ -103,7 +103,9 @@ async Task GenerateApiDocs(string wwwrootPath) typeof(Parcel.NET.Dhl.Shipping.IDhlShippingClient).Assembly, typeof(Parcel.NET.Dhl.Tracking.DhlTrackingClient).Assembly, typeof(Parcel.NET.GoExpress.GoExpressOptions).Assembly, - typeof(Parcel.NET.GoExpress.Shipping.IGoExpressShippingClient).Assembly + typeof(Parcel.NET.GoExpress.Shipping.IGoExpressShippingClient).Assembly, + typeof(Parcel.NET.LetterXpress.LetterXpressOptions).Assembly, + typeof(Parcel.NET.LetterXpress.Letters.ILetterXpressClient).Assembly }; var xmlDocs = new Dictionary(); diff --git a/docs/Parcel.NET.Docs/wwwroot/api-docs.json b/docs/Parcel.NET.Docs/wwwroot/api-docs.json index 20cab6b..1e22e4c 100644 --- a/docs/Parcel.NET.Docs/wwwroot/api-docs.json +++ b/docs/Parcel.NET.Docs/wwwroot/api-docs.json @@ -1570,5 +1570,1037 @@ "description": "Gets or sets the weekend or holiday indicator for this time window." } ] + }, + { + "typeName": "LetterXpressBuilder", + "fullName": "Parcel.NET.LetterXpress.LetterXpressBuilder", + "members": [ + { + "name": "Services", + "kind": "Property", + "type": "IServiceCollection", + "description": "Gets the underlying service collection." + } + ] + }, + { + "typeName": "LetterXpressOptions", + "fullName": "Parcel.NET.LetterXpress.LetterXpressOptions", + "members": [ + { + "name": "Username", + "kind": "Property", + "type": "string", + "description": "Gets or sets the LetterXpress account username. Generated under \u0022Mein Konto \u003E Zugangsdaten \u003E LXP API\u0022." + }, + { + "name": "ApiKey", + "kind": "Property", + "type": "string", + "description": "Gets or sets the LetterXpress API key. Generated under \u0022Mein Konto \u003E Zugangsdaten \u003E LXP API\u0022." + }, + { + "name": "UseTestMode", + "kind": "Property", + "type": "bool", + "description": "Gets or sets a value indicating whether to use the LetterXpress test mode. In test mode (\u0022mode\u0022: \u0022test\u0022) submitted jobs are not processed but stored in the \u0022Postbox\u0022 where they can be reviewed, deleted, or sent. Postbox jobs are deleted after 7 days. When , jobs are processed directly (\u0022mode\u0022: \u0022live\u0022)." + }, + { + "name": "CustomBaseUrl", + "kind": "Property", + "type": "string", + "description": "Gets or sets a custom base URL override. When set, this takes precedence over the default production URL." + }, + { + "name": "BaseUrl", + "kind": "Property", + "type": "string", + "description": "Gets the effective API base URL. Uses if set, otherwise the production URL. A trailing slash is appended if missing so that relative request URIs (e.g. balance, printjobs) resolve correctly." + } + ] + }, + { + "typeName": "LetterXpressServiceCollectionExtensions", + "fullName": "Parcel.NET.LetterXpress.LetterXpressServiceCollectionExtensions", + "members": [ + { + "name": "AddLetterXpress(IServiceCollection, Action\u003CLetterXpressOptions\u003E)", + "kind": "Method", + "type": "LetterXpressBuilder", + "description": null + } + ] + }, + { + "typeName": "Balance", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.Balance", + "members": [ + { + "name": "Amount", + "kind": "Property", + "type": "Decimal", + "description": "Gets the current balance." + }, + { + "name": "Currency", + "kind": "Property", + "type": "string", + "description": "Gets the currency (e.g. EUR)." + } + ] + }, + { + "typeName": "BankForm", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.BankForm", + "members": [ + { + "name": "Included", + "kind": "Property", + "type": "bool", + "description": "Gets or sets a value indicating whether the bank form is already included in the PDF\u0027s last sheet. When , the payment detail fields below are ignored and the last (blank) sheet is used." + }, + { + "name": "Payee", + "kind": "Property", + "type": "string", + "description": "Gets or sets the payee. Max. 27 characters. Ignored when is ." + }, + { + "name": "Iban", + "kind": "Property", + "type": "string", + "description": "Gets or sets the IBAN. Max. 34 characters." + }, + { + "name": "Bic", + "kind": "Property", + "type": "string", + "description": "Gets or sets the BIC. Max. 11 characters." + }, + { + "name": "Amount", + "kind": "Property", + "type": "string", + "description": "Gets or sets the amount. Max. 12 characters." + }, + { + "name": "PurposeOfPayment", + "kind": "Property", + "type": "string", + "description": "Gets or sets the purpose of payment. Max. 27 characters." + }, + { + "name": "PurposeOfPayment2", + "kind": "Property", + "type": "string", + "description": "Gets or sets the second purpose of payment line. Max. 27 characters." + } + ] + }, + { + "typeName": "EmailJob", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailJob", + "members": [ + { + "name": "Id", + "kind": "Property", + "type": "Int64", + "description": "Gets the e-mail job ID." + }, + { + "name": "EmailSender", + "kind": "Property", + "type": "string", + "description": "Gets the sender e-mail address." + }, + { + "name": "EmailReceiver", + "kind": "Property", + "type": "string", + "description": "Gets the recipient e-mail address." + }, + { + "name": "EmailOption", + "kind": "Property", + "type": "string", + "description": "Gets the SMART@MAIL option (maildirect, mailplus, mailsecure)." + }, + { + "name": "SentAt", + "kind": "Property", + "type": "DateTimeOffset?", + "description": "Gets the timestamp the e-mail was sent, if available." + }, + { + "name": "Amount", + "kind": "Property", + "type": "Decimal", + "description": "Gets the net amount charged." + }, + { + "name": "Vat", + "kind": "Property", + "type": "Decimal", + "description": "Gets the VAT." + }, + { + "name": "Status", + "kind": "Property", + "type": "string", + "description": "Gets the job status (e.g. queue, hold, success)." + }, + { + "name": "Subject", + "kind": "Property", + "type": "string", + "description": "Gets the e-mail subject." + }, + { + "name": "Content", + "kind": "Property", + "type": "string", + "description": "Gets the e-mail content." + }, + { + "name": "Footer", + "kind": "Property", + "type": "string", + "description": "Gets the e-mail footer." + }, + { + "name": "CreatedAt", + "kind": "Property", + "type": "DateTimeOffset?", + "description": "Gets the creation timestamp." + }, + { + "name": "PrintJobId", + "kind": "Property", + "type": "Int64?", + "description": "Gets the ID of the associated print job, when the e-mail job is part of a serial response." + }, + { + "name": "PrintJob", + "kind": "Property", + "type": "PrintJob", + "description": "Gets the associated print job, returned for MailPlus jobs." + } + ] + }, + { + "typeName": "EmailJobResult", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailJobResult", + "members": [ + { + "name": "EmailJobs", + "kind": "Property", + "type": "IReadOnlyList\u003CEmailJob\u003E", + "description": "Gets the created e-mail jobs." + }, + { + "name": "PrintJobs", + "kind": "Property", + "type": "IReadOnlyList\u003CPrintJob\u003E", + "description": "Gets any print jobs created alongside the e-mail jobs (e.g. for MailPlus or pages without a white code)." + } + ] + }, + { + "typeName": "EmailLetterOptions", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.EmailLetterOptions", + "members": [ + { + "name": "EmailOption", + "kind": "Property", + "type": "EmailOption", + "description": "Gets or sets the SMART@MAIL delivery option." + }, + { + "name": "EmailReceiver", + "kind": "Property", + "type": "string", + "description": "Gets or sets the valid recipient e-mail address." + } + ] + }, + { + "typeName": "ILetterXpressClient", + "fullName": "Parcel.NET.LetterXpress.Letters.ILetterXpressClient", + "members": [ + { + "name": "GetBalanceAsync(CancellationToken)", + "kind": "Method", + "type": "Task\u003CBalance\u003E", + "description": null + }, + { + "name": "GetPriceAsync(PriceRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPriceResult\u003E", + "description": null + }, + { + "name": "ListPrintJobsAsync(PrintJobFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CPrintJob\u003E\u003E", + "description": null + }, + { + "name": "CreatePrintJobAsync(LetterRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "GetPrintJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "UpdatePrintJobAsync(Int64, PrintJobUpdate, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "DeletePrintJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task", + "description": null + }, + { + "name": "CreateEmailJobAsync(LetterRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJobResult\u003E", + "description": null + }, + { + "name": "GetEmailJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJob\u003E", + "description": null + }, + { + "name": "UpdateEmailJobAsync(Int64, string, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJob\u003E", + "description": null + }, + { + "name": "DeleteEmailJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task", + "description": null + }, + { + "name": "ListEmailJobsAsync(EmailJobFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CEmailJob\u003E\u003E", + "description": null + }, + { + "name": "ListTransactionsAsync(TransactionFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CTransaction\u003E\u003E", + "description": null + }, + { + "name": "ListInvoicesAsync(int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CInvoice\u003E\u003E", + "description": null + }, + { + "name": "GetInvoiceAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CInvoice\u003E", + "description": null + } + ] + }, + { + "typeName": "Invoice", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.Invoice", + "members": [ + { + "name": "Id", + "kind": "Property", + "type": "Int64", + "description": "Gets the invoice ID." + }, + { + "name": "Amount", + "kind": "Property", + "type": "Decimal", + "description": "Gets the net amount." + }, + { + "name": "Vat", + "kind": "Property", + "type": "Decimal", + "description": "Gets the VAT." + }, + { + "name": "InvoiceDate", + "kind": "Property", + "type": "DateOnly?", + "description": "Gets the invoice date." + }, + { + "name": "Base64Data", + "kind": "Property", + "type": "string", + "description": "Gets the invoice PDF as base64, returned only when retrieving a single invoice." + } + ] + }, + { + "typeName": "LetterBackgrounds", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterBackgrounds", + "members": [ + { + "name": "FirstPage", + "kind": "Property", + "type": "byte[]", + "description": "Gets or sets the background PDF for the first page." + }, + { + "name": "OtherPages", + "kind": "Property", + "type": "byte[]", + "description": "Gets or sets the background PDF for all other pages." + } + ] + }, + { + "typeName": "LetterRequest", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterRequest", + "members": [ + { + "name": "File", + "kind": "Property", + "type": "byte[]", + "description": "Gets or sets the letter PDF as raw bytes. Max. 50 MB." + }, + { + "name": "Specification", + "kind": "Property", + "type": "LetterSpecification", + "description": "Gets or sets the print and shipping specification." + }, + { + "name": "FilenameOriginal", + "kind": "Property", + "type": "string", + "description": "Gets or sets the original file name (e.g. Rechnung-123456.pdf). Optional; necessary for LXP SMART@MAIL and returned when retrieving jobs." + }, + { + "name": "Registered", + "kind": "Property", + "type": "RegisteredMail", + "description": "Gets or sets the registered mail option. Registered mail can only be sent nationally. Defaults to ." + }, + { + "name": "DispatchDate", + "kind": "Property", + "type": "DateOnly?", + "description": "Gets or sets the dispatch date. Optional; must be in the future." + }, + { + "name": "SerialLetter", + "kind": "Property", + "type": "SerialLetterOptions", + "description": "Gets or sets serial letter options. Cannot be combined with ." + }, + { + "name": "EmailLetter", + "kind": "Property", + "type": "EmailLetterOptions", + "description": "Gets or sets LXP SMART@MAIL e-mail options for a single job. Cannot be combined with ." + }, + { + "name": "Attachments", + "kind": "Property", + "type": "IReadOnlyList\u003Cbyte[]\u003E", + "description": "Gets or sets additional attachments as raw PDF bytes. Each attachment must be A4 portrait (29.7 x 21.0 cm). The order is preserved." + }, + { + "name": "Backgrounds", + "kind": "Property", + "type": "LetterBackgrounds", + "description": "Gets or sets the letter background PDFs." + }, + { + "name": "TermsAndConditions", + "kind": "Property", + "type": "TermsAndConditions", + "description": "Gets or sets the terms and conditions PDF." + }, + { + "name": "BankForm", + "kind": "Property", + "type": "BankForm", + "description": "Gets or sets the bank transfer form." + }, + { + "name": "Notice", + "kind": "Property", + "type": "string", + "description": "Gets or sets an arbitrary notice stored with the job. Max. 255 characters." + } + ] + }, + { + "typeName": "LetterSpecification", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.LetterSpecification", + "members": [ + { + "name": "Color", + "kind": "Property", + "type": "LetterColor", + "description": "Gets or sets the print color. Defaults to ." + }, + { + "name": "Mode", + "kind": "Property", + "type": "PrintMode", + "description": "Gets or sets the print mode. Defaults to ." + }, + { + "name": "Shipping", + "kind": "Property", + "type": "ShippingType", + "description": "Gets or sets the shipping destination. Defaults to ." + }, + { + "name": "C4", + "kind": "Property", + "type": "bool", + "description": "Gets or sets a value indicating whether to use a C4 envelope. Optional, applies to letters under 9 sheets." + } + ] + }, + { + "typeName": "LetterXpressClient", + "fullName": "Parcel.NET.LetterXpress.Letters.LetterXpressClient", + "members": [ + { + "name": "GetBalanceAsync(CancellationToken)", + "kind": "Method", + "type": "Task\u003CBalance\u003E", + "description": null + }, + { + "name": "GetPriceAsync(PriceRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPriceResult\u003E", + "description": null + }, + { + "name": "ListPrintJobsAsync(PrintJobFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CPrintJob\u003E\u003E", + "description": null + }, + { + "name": "CreatePrintJobAsync(LetterRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "GetPrintJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "UpdatePrintJobAsync(Int64, PrintJobUpdate, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPrintJob\u003E", + "description": null + }, + { + "name": "DeletePrintJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task", + "description": null + }, + { + "name": "CreateEmailJobAsync(LetterRequest, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJobResult\u003E", + "description": null + }, + { + "name": "GetEmailJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJob\u003E", + "description": null + }, + { + "name": "UpdateEmailJobAsync(Int64, string, CancellationToken)", + "kind": "Method", + "type": "Task\u003CEmailJob\u003E", + "description": null + }, + { + "name": "DeleteEmailJobAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task", + "description": null + }, + { + "name": "ListEmailJobsAsync(EmailJobFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CEmailJob\u003E\u003E", + "description": null + }, + { + "name": "ListTransactionsAsync(TransactionFilter?, int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CTransaction\u003E\u003E", + "description": null + }, + { + "name": "ListInvoicesAsync(int?, CancellationToken)", + "kind": "Method", + "type": "Task\u003CPagedResult\u003CInvoice\u003E\u003E", + "description": null + }, + { + "name": "GetInvoiceAsync(Int64, CancellationToken)", + "kind": "Method", + "type": "Task\u003CInvoice\u003E", + "description": null + } + ] + }, + { + "typeName": "LetterXpressLettersExtensions", + "fullName": "Parcel.NET.LetterXpress.Letters.LetterXpressLettersExtensions", + "members": [ + { + "name": "AddLetterXpressLetters(LetterXpressBuilder)", + "kind": "Method", + "type": "LetterXpressBuilder", + "description": null + } + ] + }, + { + "typeName": "PagedResult\u00601", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PagedResult\u00601", + "members": [ + { + "name": "Items", + "kind": "Property", + "type": "IReadOnlyList\u003CT\u003E", + "description": "Gets the items on the current page." + }, + { + "name": "Pagination", + "kind": "Property", + "type": "Pagination", + "description": "Gets the pagination metadata, if available." + } + ] + }, + { + "typeName": "Pagination", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.Pagination", + "members": [ + { + "name": "Total", + "kind": "Property", + "type": "int", + "description": "Gets the total number of items." + }, + { + "name": "Count", + "kind": "Property", + "type": "int", + "description": "Gets the number of items on the current page." + }, + { + "name": "CurrentPage", + "kind": "Property", + "type": "int", + "description": "Gets the current page number." + }, + { + "name": "LastPage", + "kind": "Property", + "type": "int", + "description": "Gets the last page number." + }, + { + "name": "PerPage", + "kind": "Property", + "type": "int", + "description": "Gets the number of items per page." + }, + { + "name": "FirstPageUrl", + "kind": "Property", + "type": "string", + "description": "Gets the URL of the first page, if any." + }, + { + "name": "LastPageUrl", + "kind": "Property", + "type": "string", + "description": "Gets the URL of the last page, if any." + }, + { + "name": "NextPageUrl", + "kind": "Property", + "type": "string", + "description": "Gets the URL of the next page, if any." + }, + { + "name": "PrevPageUrl", + "kind": "Property", + "type": "string", + "description": "Gets the URL of the previous page, if any." + } + ] + }, + { + "typeName": "PriceRequest", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PriceRequest", + "members": [ + { + "name": "Pages", + "kind": "Property", + "type": "int", + "description": "Gets or sets the number of pages." + }, + { + "name": "Color", + "kind": "Property", + "type": "LetterColor", + "description": "Gets or sets the print color. Defaults to ." + }, + { + "name": "Mode", + "kind": "Property", + "type": "PrintMode", + "description": "Gets or sets the print mode. Defaults to ." + }, + { + "name": "Shipping", + "kind": "Property", + "type": "ShippingType", + "description": "Gets or sets the shipping destination. Only and are valid for price requests." + }, + { + "name": "C4", + "kind": "Property", + "type": "bool", + "description": "Gets or sets a value indicating whether to use a C4 envelope." + }, + { + "name": "EmailOption", + "kind": "Property", + "type": "EmailOption?", + "description": "Gets or sets the SMART@MAIL e-mail option. Optional." + }, + { + "name": "Registered", + "kind": "Property", + "type": "RegisteredMail", + "description": "Gets or sets the registered mail option. Optional; only national shipping is supported for registered mail." + } + ] + }, + { + "typeName": "PriceResult", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PriceResult", + "members": [ + { + "name": "Price", + "kind": "Property", + "type": "Decimal", + "description": "Gets the calculated gross price." + }, + { + "name": "Pages", + "kind": "Property", + "type": "int?", + "description": "Gets the number of pages the price was calculated for." + }, + { + "name": "Color", + "kind": "Property", + "type": "string", + "description": "Gets the print color the price was calculated for." + }, + { + "name": "Mode", + "kind": "Property", + "type": "string", + "description": "Gets the print mode the price was calculated for." + }, + { + "name": "Shipping", + "kind": "Property", + "type": "string", + "description": "Gets the shipping destination the price was calculated for." + } + ] + }, + { + "typeName": "PrintJob", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJob", + "members": [ + { + "name": "Id", + "kind": "Property", + "type": "Int64", + "description": "Gets the print job ID." + }, + { + "name": "Shipping", + "kind": "Property", + "type": "string", + "description": "Gets the shipping destination (e.g. national)." + }, + { + "name": "Mode", + "kind": "Property", + "type": "string", + "description": "Gets the print mode (e.g. simplex)." + }, + { + "name": "Color", + "kind": "Property", + "type": "string", + "description": "Gets the print color (1 for black/white, 4 for color)." + }, + { + "name": "C4", + "kind": "Property", + "type": "bool", + "description": "Gets a value indicating whether a C4 envelope is used." + }, + { + "name": "Registered", + "kind": "Property", + "type": "string", + "description": "Gets the registered mail option (r1, r2), if any." + }, + { + "name": "BankForm", + "kind": "Property", + "type": "bool", + "description": "Gets a value indicating whether a bank form is attached." + }, + { + "name": "Notice", + "kind": "Property", + "type": "string", + "description": "Gets the notice stored with the job." + }, + { + "name": "Status", + "kind": "Property", + "type": "string", + "description": "Gets the job status (e.g. queue, hold, done, canceled, draft)." + }, + { + "name": "DispatchDate", + "kind": "Property", + "type": "string", + "description": "Gets the dispatch date, if set." + }, + { + "name": "FilenameOriginal", + "kind": "Property", + "type": "string", + "description": "Gets the original file name, if provided." + }, + { + "name": "CreatedAt", + "kind": "Property", + "type": "DateTimeOffset?", + "description": "Gets the creation timestamp. The API reports times in German local time (Europe/Berlin)." + }, + { + "name": "UpdatedAt", + "kind": "Property", + "type": "DateTimeOffset?", + "description": "Gets the last update timestamp. The API reports times in German local time (Europe/Berlin)." + }, + { + "name": "Items", + "kind": "Property", + "type": "IReadOnlyList\u003CPrintJobItem\u003E", + "description": "Gets the recipient items of the job." + } + ] + }, + { + "typeName": "PrintJobItem", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJobItem", + "members": [ + { + "name": "Address", + "kind": "Property", + "type": "string", + "description": "Gets the recipient address as a single line." + }, + { + "name": "Pages", + "kind": "Property", + "type": "int", + "description": "Gets the number of pages." + }, + { + "name": "Amount", + "kind": "Property", + "type": "Decimal", + "description": "Gets the net amount charged for this item." + }, + { + "name": "Vat", + "kind": "Property", + "type": "Decimal", + "description": "Gets the VAT for this item." + }, + { + "name": "Status", + "kind": "Property", + "type": "string", + "description": "Gets the item status (e.g. queue, sent)." + }, + { + "name": "TrackingCode", + "kind": "Property", + "type": "string", + "description": "Gets the registered mail tracking code, if available." + }, + { + "name": "Base64Data", + "kind": "Property", + "type": "string", + "description": "Gets the rendered letter PDF as base64, returned only when retrieving a single job." + } + ] + }, + { + "typeName": "PrintJobUpdate", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.PrintJobUpdate", + "members": [ + { + "name": "DispatchDate", + "kind": "Property", + "type": "DateOnly?", + "description": "Gets or sets the new dispatch date." + }, + { + "name": "Registered", + "kind": "Property", + "type": "RegisteredMail?", + "description": "Gets or sets the new registered mail option." + }, + { + "name": "Notice", + "kind": "Property", + "type": "string", + "description": "Gets or sets the new notice." + }, + { + "name": "Color", + "kind": "Property", + "type": "LetterColor?", + "description": "Gets or sets the new print color." + }, + { + "name": "Mode", + "kind": "Property", + "type": "PrintMode?", + "description": "Gets or sets the new print mode." + }, + { + "name": "Shipping", + "kind": "Property", + "type": "ShippingType?", + "description": "Gets or sets the new shipping destination." + }, + { + "name": "C4", + "kind": "Property", + "type": "bool?", + "description": "Gets or sets the new C4 envelope flag." + } + ] + }, + { + "typeName": "SerialLetterOptions", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.SerialLetterOptions", + "members": [ + { + "name": "PagesSeparatorType", + "kind": "Property", + "type": "SeparatorType", + "description": "Gets or sets the separator type." + }, + { + "name": "PagesSeparatorValue", + "kind": "Property", + "type": "string", + "description": "Gets or sets the separator value: a keyword when is , or a page count when it is ." + } + ] + }, + { + "typeName": "TermsAndConditions", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.TermsAndConditions", + "members": [ + { + "name": "Terms", + "kind": "Property", + "type": "byte[]", + "description": "Gets or sets the terms and conditions PDF." + }, + { + "name": "OnAllPages", + "kind": "Property", + "type": "bool", + "description": "Gets or sets a value indicating whether the terms are inserted behind all pages () or only behind the first page ()." + } + ] + }, + { + "typeName": "Transaction", + "fullName": "Parcel.NET.LetterXpress.Letters.Models.Transaction", + "members": [ + { + "name": "Amount", + "kind": "Property", + "type": "Decimal", + "description": "Gets the amount (negative for charges, positive for top-ups)." + }, + { + "name": "Currency", + "kind": "Property", + "type": "string", + "description": "Gets the currency (e.g. EUR)." + }, + { + "name": "Description", + "kind": "Property", + "type": "string", + "description": "Gets the description." + }, + { + "name": "CreatedAt", + "kind": "Property", + "type": "DateTimeOffset?", + "description": "Gets the creation timestamp." + } + ] } ] \ No newline at end of file diff --git a/docs/Parcel.NET.Docs/wwwroot/content-index.json b/docs/Parcel.NET.Docs/wwwroot/content-index.json index 0742a2b..74225f2 100644 --- a/docs/Parcel.NET.Docs/wwwroot/content-index.json +++ b/docs/Parcel.NET.Docs/wwwroot/content-index.json @@ -61,6 +61,28 @@ ], "searchText": "Overview The GoExpressShippingClient implements the GO! Express Realtime Order \u0026amp; Label API (GO! Connect), providing: Shipment creation with label generation Label re-generation for existing shipments Shipment cancellation Important: AX4 Web-Portal Interaction If you also use the AX4 Web-Portal provided by Siemens, be aware of the following: Delayed visibility : Shipments created via GO! Connect are not immediately visible in the AX4 Web-Portal. They are imported as EDI orders with a delay. C" }, + { + "slug": "carriers/letterxpress-letters", + "title": "LetterXpress Letters", + "category": "Carriers", + "subcategory": "LetterXpress", + "order": 1, + "description": "Send physical letters and SMART@MAIL e-mails, query prices and balance, and manage print jobs with the LetterXpress (A\u0026O Fischer) API v3.", + "apiRef": "ILetterXpressClient", + "headings": [ + "Overview", + "Installation", + "Registration", + "Authentication \u0026amp; modes", + "Examples", + "Balance and price", + "Create a print job", + "SMART@MAIL e-mail job", + "Listing and filtering", + "Notes \u0026amp; limits" + ], + "searchText": "Overview LetterXpressClient implements the LetterXpress (A\u0026amp;O Fischer GmbH \u0026amp; Co. KG) API v3 \u2014 a hybrid print-and-mail service: you upload a PDF, LetterXpress prints it and sends it as a physical letter, optionally combined with the SMART@MAIL e-mail product. Because LetterXpress is a letter/print-mail product rather than a parcel carrier, it does not implement the carrier-agnostic IShipmentService / ITrackingService abstractions. It exposes its own ILetterXpressClient interface, mirroring" + }, { "slug": "carriers/dhl-tracking", "title": "DHL Tracking", diff --git a/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md b/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md new file mode 100644 index 0000000..9203808 --- /dev/null +++ b/docs/Parcel.NET.Docs/wwwroot/content/carriers/letterxpress-letters.md @@ -0,0 +1,126 @@ +--- +title: LetterXpress Letters +category: Carriers +subcategory: LetterXpress +order: 1 +description: Send physical letters and SMART@MAIL e-mails, query prices and balance, and manage print jobs with the LetterXpress (A&O Fischer) API v3. +apiRef: ILetterXpressClient +--- + +## Overview + +`LetterXpressClient` implements the [LetterXpress](https://www.letterxpress.de/) (A&O Fischer GmbH & Co. KG) API v3 — a hybrid print-and-mail service: you upload a PDF, LetterXpress prints it and sends it as a physical letter, optionally combined with the **SMART@MAIL** e-mail product. + +Because LetterXpress is a letter/print-mail product rather than a parcel carrier, it does **not** implement the carrier-agnostic `IShipmentService`/`ITrackingService` abstractions. It exposes its own `ILetterXpressClient` interface, mirroring the DHL Internetmarke design. + +Supported operations: + +- **Account** — query balance and price +- **Print jobs** — create, retrieve (incl. rendered PDF), update, delete, and list +- **E-mail jobs (SMART@MAIL)** — create (`maildirect` / `mailplus` / `mailsecure`), retrieve, update, delete, and list +- **Accounting** — list transactions and invoices (incl. invoice PDF) + +## Installation + +```bash +dotnet add package Parcel.NET.LetterXpress.Letters +# or the meta-package +dotnet add package Parcel.NET.LetterXpress.All +``` + +## Registration + +```csharp +builder.Services.AddLetterXpress(options => +{ + options.Username = "your-username"; // Mein Konto > Zugangsdaten > LXP API + options.ApiKey = "your-api-key"; + options.UseTestMode = true; // true => Postbox, no real processing +}) +.AddLetterXpressLetters(); +``` + +## Authentication & modes + +Authentication is sent in the JSON **body** of every request (an `auth` object with `username`, `apikey`, and `mode`) — there is no auth header. The client handles this for you. + +- **Test mode** (`UseTestMode = true`): jobs are placed in the **Postbox** instead of being processed (and therefore not dispatched). Postbox jobs can be reviewed, deleted, or sent, and are deleted automatically after 7 days. +- **Live mode** (`UseTestMode = false`): jobs are processed and dispatched directly. + +> LetterXpress is a server-to-server API and intentionally does **not** enable CORS. Use it from your backend, never directly from a browser — that would expose your API key. + +## Examples + +### Balance and price + +```csharp +var client = serviceProvider.GetRequiredService(); + +Balance balance = await client.GetBalanceAsync(); +Console.WriteLine($"{balance.Amount} {balance.Currency}"); + +PriceResult price = await client.GetPriceAsync(new PriceRequest +{ + Pages = 1, + Color = LetterColor.BlackWhite, + Mode = PrintMode.Simplex, + Shipping = ShippingType.National // price supports only National or International +}); +``` + +### Create a print job + +The PDF is supplied as raw bytes; the client computes the base64 encoding and MD5 checksum automatically. + +```csharp +PrintJob job = await client.CreatePrintJobAsync(new LetterRequest +{ + File = await File.ReadAllBytesAsync("invoice.pdf"), + FilenameOriginal = "invoice.pdf", + Specification = new LetterSpecification + { + Color = LetterColor.Color, + Mode = PrintMode.Duplex, + Shipping = ShippingType.National + }, + Registered = RegisteredMail.Einschreiben // national only +}); + +// Within 15 minutes you can still update or delete it: +await client.UpdatePrintJobAsync(job.Id, new PrintJobUpdate { Notice = "Customer 4711" }); +await client.DeletePrintJobAsync(job.Id); +``` + +### SMART@MAIL e-mail job + +```csharp +EmailJobResult result = await client.CreateEmailJobAsync(new LetterRequest +{ + File = await File.ReadAllBytesAsync("invoice.pdf"), + Specification = new LetterSpecification { Shipping = ShippingType.National }, + EmailLetter = new EmailLetterOptions + { + EmailOption = EmailOption.MailDirect, + EmailReceiver = "customer@example.com" + } +}); + +foreach (var email in result.EmailJobs) + Console.WriteLine($"{email.Id}: {email.EmailReceiver} ({email.Status})"); +``` + +### Listing and filtering + +```csharp +PagedResult queued = await client.ListPrintJobsAsync(PrintJobFilter.Queue); +PagedResult sent = await client.ListEmailJobsAsync(EmailJobFilter.Success); +PagedResult tx = await client.ListTransactionsAsync(TransactionFilter.PrintJobs); +PagedResult invoices = await client.ListInvoicesAsync(); +``` + +## Notes & limits + +- **Limits:** max. 50 MB per request (validated locally) and 120 requests/minute. Throttling/retry on HTTP 429 is left to your `HttpClient` pipeline (e.g. a `Microsoft.Extensions.Http.Resilience` handler). +- **Timestamps:** the API returns timestamps without a timezone designator, in German local time (Europe/Berlin, CET/CEST). The client parses them into `DateTimeOffset` with the correct (DST-aware) offset. +- **Constraints enforced by the client:** registered mail is national-only; a serial letter cannot be combined with a single `EmailLetter`; price requests accept only `National`/`International` shipping. +- **Errors** surface as `LetterXpressException` carrying the HTTP/body status code and the raw response. diff --git a/docs/Parcel.NET.Docs/wwwroot/sitemap.xml b/docs/Parcel.NET.Docs/wwwroot/sitemap.xml index 219c2bc..7bcdbb7 100644 --- a/docs/Parcel.NET.Docs/wwwroot/sitemap.xml +++ b/docs/Parcel.NET.Docs/wwwroot/sitemap.xml @@ -4,6 +4,7 @@ https://emuuu.github.io/Parcel.NET/docs/api/overview https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-shipping https://emuuu.github.io/Parcel.NET/docs/carriers/goexpress-shipping + https://emuuu.github.io/Parcel.NET/docs/carriers/letterxpress-letters https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-tracking https://emuuu.github.io/Parcel.NET/docs/carriers/goexpress-tracking https://emuuu.github.io/Parcel.NET/docs/carriers/dhl-authentication diff --git a/src/Parcel.NET.All/Parcel.NET.All.csproj b/src/Parcel.NET.All/Parcel.NET.All.csproj index 89e71a7..3397da7 100644 --- a/src/Parcel.NET.All/Parcel.NET.All.csproj +++ b/src/Parcel.NET.All/Parcel.NET.All.csproj @@ -2,13 +2,14 @@ Parcel.NET.All - Meta-package that includes all Parcel.NET carrier packages (DHL, GO! Express). - shipping;tracking;logistics;dhl;goexpress;parcel;meta-package + Meta-package that includes all Parcel.NET carrier packages (DHL, GO! Express, LetterXpress). + shipping;tracking;logistics;dhl;goexpress;letterxpress;parcel;meta-package + diff --git a/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj b/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj new file mode 100644 index 0000000..77c31c7 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.All/Parcel.NET.LetterXpress.All.csproj @@ -0,0 +1,14 @@ + + + + Parcel.NET.LetterXpress.All + Meta-package that includes all LetterXpress (A&O Fischer) carrier packages for Parcel.NET. + letter;mail;post;logistics;letterxpress;meta-package + + + + + + + + diff --git a/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs b/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs new file mode 100644 index 0000000..cb071e7 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/ILetterXpressClient.cs @@ -0,0 +1,151 @@ +using Parcel.NET.LetterXpress.Letters.Models; + +namespace Parcel.NET.LetterXpress.Letters; + +/// +/// Client for the LetterXpress (A&O Fischer) API v3. +/// +public interface ILetterXpressClient +{ + /// + /// Retrieves the account balance (GET /v3/balance). + /// + /// A cancellation token. + /// The account balance. + /// Thrown when the API returns an error. + Task GetBalanceAsync(CancellationToken cancellationToken = default); + + /// + /// Calculates the price for a letter (GET /v3/price). + /// + /// The price request. + /// A cancellation token. + /// The price result. + /// Thrown when the API returns an error. + Task GetPriceAsync(PriceRequest request, CancellationToken cancellationToken = default); + + /// + /// Lists print jobs (GET /v3/printjobs), ordered from newest to oldest. + /// + /// Optional status filter. + /// Optional page number. + /// A cancellation token. + /// A paged list of print jobs. + /// Thrown when the API returns an error. + Task> ListPrintJobsAsync(PrintJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default); + + /// + /// Submits a print job (POST /v3/printjobs). + /// + /// The letter to submit. + /// A cancellation token. + /// The created print job. + /// Thrown when the API returns an error. + Task CreatePrintJobAsync(LetterRequest request, CancellationToken cancellationToken = default); + + /// + /// Retrieves a single print job, including the rendered PDF (GET /v3/printjobs/{id}). + /// + /// The print job ID. + /// A cancellation token. + /// The print job. + /// Thrown when the API returns an error. + Task GetPrintJobAsync(long id, CancellationToken cancellationToken = default); + + /// + /// Changes a print job (PUT /v3/printjobs/{id}). Only possible within the first 15 minutes + /// after submission, and the PDF itself cannot be changed. + /// + /// The print job ID. + /// The changes to apply. + /// A cancellation token. + /// The updated print job. + /// Thrown when the API returns an error. + Task UpdatePrintJobAsync(long id, PrintJobUpdate update, CancellationToken cancellationToken = default); + + /// + /// Deletes a print job (DELETE /v3/printjobs/{id}). Only possible within the first 15 minutes + /// after submission; jobs with status done cannot be deleted. + /// + /// The print job ID. + /// A cancellation token. + /// Thrown when the API returns an error. + Task DeletePrintJobAsync(long id, CancellationToken cancellationToken = default); + + /// + /// Submits an e-mail job (POST /v3/emailjobs) using LXP SMART@MAIL. + /// + /// The letter to submit. Provide for a single + /// job, or omit it and use a white code in the PDF for serial processing. + /// A cancellation token. + /// The created e-mail job(s) and any associated print jobs. + /// Thrown when the API returns an error. + Task CreateEmailJobAsync(LetterRequest request, CancellationToken cancellationToken = default); + + /// + /// Retrieves a single e-mail job (GET /v3/emailjobs/{id}). + /// + /// The e-mail job ID. + /// A cancellation token. + /// The e-mail job. + /// Thrown when the API returns an error. + Task GetEmailJobAsync(long id, CancellationToken cancellationToken = default); + + /// + /// Changes an e-mail job's recipient (PUT /v3/emailjobs/{id}). Only possible within the first + /// 15 minutes after submission. + /// + /// The e-mail job ID. + /// The new recipient e-mail address. + /// A cancellation token. + /// The updated e-mail job. + /// Thrown when the API returns an error. + Task UpdateEmailJobAsync(long id, string emailReceiver, CancellationToken cancellationToken = default); + + /// + /// Deletes an e-mail job (DELETE /v3/emailjobs/{id}). Only possible within the first 15 minutes + /// after submission; jobs with status success cannot be deleted. + /// + /// The e-mail job ID. + /// A cancellation token. + /// Thrown when the API returns an error. + Task DeleteEmailJobAsync(long id, CancellationToken cancellationToken = default); + + /// + /// Lists e-mail jobs (GET /v3/emailjobs). + /// + /// Optional status filter. + /// Optional page number. + /// A cancellation token. + /// A paged list of e-mail jobs. + /// Thrown when the API returns an error. + Task> ListEmailJobsAsync(EmailJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default); + + /// + /// Lists account transactions (GET /v3/transactions). + /// + /// Optional transaction filter. + /// Optional page number. + /// A cancellation token. + /// A paged list of transactions. + /// Thrown when the API returns an error. + Task> ListTransactionsAsync(TransactionFilter? filter = null, int? page = null, CancellationToken cancellationToken = default); + + /// + /// Lists invoices (GET /v3/invoices). + /// + /// Optional page number. + /// A cancellation token. + /// A paged list of invoices. + /// Thrown when the API returns an error. + Task> ListInvoicesAsync(int? page = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a single invoice, including the PDF (GET /v3/invoices/{id}). + /// + /// The invoice ID. + /// A cancellation token. + /// The invoice. + /// Thrown when the API returns an error. + Task GetInvoiceAsync(long id, CancellationToken cancellationToken = default); +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs new file mode 100644 index 0000000..bfe929a --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressApiModels.cs @@ -0,0 +1,262 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Parcel.NET.LetterXpress.Letters.Internal; + +// All property names are mapped to snake_case via the JsonContext naming policy, +// except where an explicit [JsonPropertyName] overrides it (e.g. "apikey"). + +internal sealed class LxAuth +{ + public string Username { get; set; } = ""; + + [JsonPropertyName("apikey")] + public string ApiKey { get; set; } = ""; + + public string Mode { get; set; } = ""; +} + +internal sealed class LxAuthRequest +{ + public LxAuth Auth { get; set; } = new(); +} + +internal sealed class LxSpecificationWire +{ + public string? Color { get; set; } + public string? Mode { get; set; } + public string? Shipping { get; set; } + public int? C4 { get; set; } + public int? Pages { get; set; } + public string? EmailOption { get; set; } +} + +internal sealed class LxSerialWire +{ + public string PagesSeparatorType { get; set; } = ""; + + // Either a JSON string (keyword) or a JSON number (page count), depending on the separator type. + public JsonNode? PagesSeparatorValue { get; set; } +} + +internal sealed class LxEmailLetterWire +{ + public string EmailOption { get; set; } = ""; + public string EmailReceiver { get; set; } = ""; +} + +internal sealed class LxBackgroundsWire +{ + public string? Base64BackgroundFirstPage { get; set; } + public string? Base64BackgroundOtherPages { get; set; } +} + +internal sealed class LxTermsWire +{ + public string Base64Terms { get; set; } = ""; + public int TermsOnAllPages { get; set; } +} + +internal sealed class LxBankFormWire +{ + public int BankFormIncluded { get; set; } + public string? Payee { get; set; } + public string? Iban { get; set; } + public string? Bic { get; set; } + public string? Amount { get; set; } + public string? PurposeOfPayment { get; set; } + public string? PurposeOfPayment2 { get; set; } +} + +internal sealed class LxLetterWire +{ + public string Base64File { get; set; } = ""; + public string Base64FileChecksum { get; set; } = ""; + public LxSpecificationWire Specification { get; set; } = new(); + public string? FilenameOriginal { get; set; } + public string? Registered { get; set; } + public string? DispatchDate { get; set; } + public LxSerialWire? SerialLetter { get; set; } + public LxEmailLetterWire? EmailLetter { get; set; } + public List? Base64Attachments { get; set; } + public LxBackgroundsWire? Backgrounds { get; set; } + public LxTermsWire? TermsAndConditions { get; set; } + public LxBankFormWire? BankForm { get; set; } + public string? Notice { get; set; } +} + +internal sealed class LxLetterRequest +{ + public LxAuth Auth { get; set; } = new(); + public LxLetterWire Letter { get; set; } = new(); +} + +internal sealed class LxPriceLetterWire +{ + public LxSpecificationWire Specification { get; set; } = new(); + public string? Registered { get; set; } +} + +internal sealed class LxPriceRequest +{ + public LxAuth Auth { get; set; } = new(); + public LxPriceLetterWire Letter { get; set; } = new(); +} + +internal sealed class LxUpdateLetterWire +{ + public string? DispatchDate { get; set; } + public string? Registered { get; set; } + public string? Notice { get; set; } + public LxSpecificationWire? Specification { get; set; } +} + +internal sealed class LxUpdateRequest +{ + public LxAuth Auth { get; set; } = new(); + public LxUpdateLetterWire Letter { get; set; } = new(); +} + +internal sealed class LxEmailWire +{ + public string EmailReceiver { get; set; } = ""; +} + +internal sealed class LxEmailUpdateRequest +{ + public LxAuth Auth { get; set; } = new(); + public LxEmailWire Email { get; set; } = new(); +} + +// ---- Response envelopes ------------------------------------------------- + +internal sealed class LxResponse +{ + public int Status { get; set; } + public string? Message { get; set; } + public T? Data { get; set; } +} + +internal sealed class LxBalanceData +{ + public decimal Balance { get; set; } + public string? Currency { get; set; } +} + +internal sealed class LxPriceEcho +{ + public LxSpecificationWire? Specification { get; set; } +} + +internal sealed class LxPriceData +{ + public decimal Price { get; set; } + public LxPriceEcho? Letter { get; set; } +} + +internal sealed class LxPrintJobItemWire +{ + public string? Address { get; set; } + public int Pages { get; set; } + public decimal Amount { get; set; } + public decimal Vat { get; set; } + public string? Status { get; set; } + public string? TrackingCode { get; set; } + public string? Base64Data { get; set; } +} + +internal sealed class LxPrintJobWire +{ + public long Id { get; set; } + public string? Shipping { get; set; } + public string? Mode { get; set; } + public string? Color { get; set; } + public int C4 { get; set; } + public string? Registered { get; set; } + public int BankForm { get; set; } + public string? Notice { get; set; } + public string? Status { get; set; } + public string? DispatchDate { get; set; } + public string? FilenameOriginal { get; set; } + public string? CreatedAt { get; set; } + public string? UpdatedAt { get; set; } + public List? Items { get; set; } +} + +internal sealed class LxPaginationWire +{ + public int Total { get; set; } + public int Count { get; set; } + public int CurrentPage { get; set; } + public int LastPage { get; set; } + public int PerPage { get; set; } + public string? FirstPageUrl { get; set; } + public string? LastPageUrl { get; set; } + public string? NextPageUrl { get; set; } + public string? PrevPageUrl { get; set; } +} + +internal sealed class LxPrintJobsData +{ + [JsonPropertyName("printjobs")] + public List? PrintJobs { get; set; } + + public LxPaginationWire? Pagination { get; set; } +} + +internal sealed class LxEmailJobWire +{ + public long Id { get; set; } + public string? EmailSender { get; set; } + public string? EmailReceiver { get; set; } + public string? EmailOption { get; set; } + public string? SentAt { get; set; } + public decimal Amount { get; set; } + public decimal Vat { get; set; } + public string? Status { get; set; } + public string? Subject { get; set; } + public string? Content { get; set; } + public string? Footer { get; set; } + public string? CreatedAt { get; set; } + public long? PrintjobId { get; set; } + public LxPrintJobWire? Printjob { get; set; } +} + +internal sealed class LxEmailCreateData +{ + [JsonPropertyName("printjobs")] + public List? PrintJobs { get; set; } + + [JsonPropertyName("emailjobs")] + public List? EmailJobs { get; set; } +} + +internal sealed class LxTransactionWire +{ + public decimal Amount { get; set; } + public string? Currency { get; set; } + public string? Description { get; set; } + public string? CreatedAt { get; set; } +} + +internal sealed class LxTransactionsData +{ + public List? Transactions { get; set; } + public LxPaginationWire? Pagination { get; set; } +} + +internal sealed class LxInvoiceWire +{ + public long Id { get; set; } + public decimal Amount { get; set; } + public decimal Vat { get; set; } + public string? InvoiceDate { get; set; } + public string? Base64Data { get; set; } +} + +internal sealed class LxInvoicesData +{ + public List? Invoices { get; set; } + public LxPaginationWire? Pagination { get; set; } +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs new file mode 100644 index 0000000..9d5bcef --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Internal/LetterXpressJsonContext.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Parcel.NET.LetterXpress.Letters.Internal; + +// Requests +[JsonSerializable(typeof(LxAuthRequest))] +[JsonSerializable(typeof(LxLetterRequest))] +[JsonSerializable(typeof(LxPriceRequest))] +[JsonSerializable(typeof(LxUpdateRequest))] +[JsonSerializable(typeof(LxEmailUpdateRequest))] +// Responses +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(LxResponse))] +[JsonSerializable(typeof(JsonElement))] +// Standalone types for JsonElement.Deserialize on polymorphic e-mail responses. +[JsonSerializable(typeof(LxEmailJobWire))] +[JsonSerializable(typeof(LxPrintJobWire))] +[JsonSerializable(typeof(LxEmailCreateData))] +[JsonSerializable(typeof(LxPaginationWire))] +// WICHTIG: snake_case-Namenskonvention (base64_file, email_receiver, dispatch_date …). +// WhenWritingNull, damit optionale Felder weggelassen werden. +// AllowReadingFromString, falls die API numerische Werte als Strings liefert. +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] +internal partial class LetterXpressJsonContext : JsonSerializerContext; diff --git a/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs b/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs new file mode 100644 index 0000000..2f6accb --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/LetterXpressClient.cs @@ -0,0 +1,836 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Options; +using Parcel.NET.LetterXpress.Internal; +using Parcel.NET.LetterXpress.Letters.Internal; +using Parcel.NET.LetterXpress.Letters.Models; + +namespace Parcel.NET.LetterXpress.Letters; + +/// +/// LetterXpress (A&O Fischer) API v3 client implementing . +/// +public class LetterXpressClient : ILetterXpressClient +{ + private readonly HttpClient _httpClient; + private readonly LetterXpressOptions _options; + + /// + /// Initializes a new instance of . + /// + /// The configured HTTP client for LetterXpress API requests. + /// LetterXpress configuration options. + public LetterXpressClient(HttpClient httpClient, IOptions options) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(options); + _httpClient = httpClient; + _options = options.Value; + } + + /// + public async Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + var response = await SendAsync( + HttpMethod.Get, "balance", AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxBalanceData, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress balance response contained no data."); + return new Balance { Amount = data.Balance, Currency = data.Currency }; + } + + /// + public async Task GetPriceAsync(PriceRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Pages <= 0) + { + throw new ArgumentException("Price requests require a positive page count.", nameof(request)); + } + + if (request.Shipping == ShippingType.Auto) + { + throw new ArgumentException( + "Price requests support only National or International shipping, not Auto.", nameof(request)); + } + + if (request.Registered != RegisteredMail.None && request.Shipping == ShippingType.International) + { + throw new ArgumentException( + "Registered mail can only be sent nationally.", nameof(request)); + } + + var payload = new LxPriceRequest + { + Auth = BuildAuth(), + Letter = new LxPriceLetterWire + { + Specification = new LxSpecificationWire + { + Pages = request.Pages, + Color = ColorToWire(request.Color), + Mode = ModeToWire(request.Mode), + Shipping = ShippingToWire(request.Shipping), + C4 = request.C4 ? 1 : 0, + EmailOption = request.EmailOption is { } eo ? EmailOptionToWire(eo) : null + }, + Registered = RegisteredToWire(request.Registered) + } + }; + + var response = await SendAsync( + HttpMethod.Get, "price", + JsonContent.Create(payload, LetterXpressJsonContext.Default.LxPriceRequest), + LetterXpressJsonContext.Default.LxResponseLxPriceData, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress price response contained no data."); + var spec = data.Letter?.Specification; + return new PriceResult + { + Price = data.Price, + Pages = spec?.Pages, + Color = spec?.Color, + Mode = spec?.Mode, + Shipping = spec?.Shipping + }; + } + + /// + public async Task> ListPrintJobsAsync( + PrintJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default) + { + var url = BuildListUrl("printjobs", filter is { } f ? PrintJobFilterToWire(f) : null, page); + + var response = await SendAsync( + HttpMethod.Get, url, AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxPrintJobsData, cancellationToken).ConfigureAwait(false); + + var data = response.Data; + return new PagedResult + { + Items = data?.PrintJobs?.Select(MapPrintJob).ToList() ?? [], + Pagination = MapPagination(data?.Pagination) + }; + } + + /// + public async Task CreatePrintJobAsync(LetterRequest request, CancellationToken cancellationToken = default) + { + ValidateLetterRequest(request); + + var payload = new LxLetterRequest { Auth = BuildAuth(), Letter = BuildLetterWire(request) }; + + var response = await SendAsync( + HttpMethod.Post, "printjobs", + JsonContent.Create(payload, LetterXpressJsonContext.Default.LxLetterRequest), + LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data."); + return MapPrintJob(data); + } + + /// + public async Task GetPrintJobAsync(long id, CancellationToken cancellationToken = default) + { + var response = await SendAsync( + HttpMethod.Get, $"printjobs/{id}", AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data."); + return MapPrintJob(data); + } + + /// + public async Task UpdatePrintJobAsync(long id, PrintJobUpdate update, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(update); + + LxSpecificationWire? spec = null; + if (update.Color is not null || update.Mode is not null || update.Shipping is not null || update.C4 is not null) + { + spec = new LxSpecificationWire + { + Color = update.Color is { } c ? ColorToWire(c) : null, + Mode = update.Mode is { } m ? ModeToWire(m) : null, + Shipping = update.Shipping is { } s ? ShippingToWire(s) : null, + C4 = update.C4 is { } c4 ? (c4 ? 1 : 0) : null + }; + } + + var payload = new LxUpdateRequest + { + Auth = BuildAuth(), + Letter = new LxUpdateLetterWire + { + DispatchDate = update.DispatchDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Registered = update.Registered is { } r ? RegisteredToWire(r) : null, + Notice = update.Notice, + Specification = spec + } + }; + + var response = await SendAsync( + HttpMethod.Put, $"printjobs/{id}", + JsonContent.Create(payload, LetterXpressJsonContext.Default.LxUpdateRequest), + LetterXpressJsonContext.Default.LxResponseLxPrintJobWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress print job response contained no data."); + return MapPrintJob(data); + } + + /// + public async Task DeletePrintJobAsync(long id, CancellationToken cancellationToken = default) + { + await SendAsync( + HttpMethod.Delete, $"printjobs/{id}", AuthBody(), + LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CreateEmailJobAsync(LetterRequest request, CancellationToken cancellationToken = default) + { + ValidateLetterRequest(request); + + var payload = new LxLetterRequest { Auth = BuildAuth(), Letter = BuildLetterWire(request) }; + + var response = await SendAsync( + HttpMethod.Post, "emailjobs", + JsonContent.Create(payload, LetterXpressJsonContext.Default.LxLetterRequest), + LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false); + + try + { + return ParseEmailCreate(response.Data); + } + catch (JsonException ex) + { + throw new LetterXpressException( + "Failed to parse LetterXpress e-mail job response.", null, null, RawTextOrNull(response.Data), ex); + } + } + + /// + public async Task GetEmailJobAsync(long id, CancellationToken cancellationToken = default) + { + var response = await SendAsync( + HttpMethod.Get, $"emailjobs/{id}", AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxEmailJobWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress e-mail job response contained no data."); + return MapEmailJob(data); + } + + /// + public async Task UpdateEmailJobAsync(long id, string emailReceiver, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(emailReceiver); + + var payload = new LxEmailUpdateRequest + { + Auth = BuildAuth(), + Email = new LxEmailWire { EmailReceiver = emailReceiver } + }; + + var response = await SendAsync( + HttpMethod.Put, $"emailjobs/{id}", + JsonContent.Create(payload, LetterXpressJsonContext.Default.LxEmailUpdateRequest), + LetterXpressJsonContext.Default.LxResponseLxEmailJobWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress e-mail job response contained no data."); + return MapEmailJob(data); + } + + /// + public async Task DeleteEmailJobAsync(long id, CancellationToken cancellationToken = default) + { + await SendAsync( + HttpMethod.Delete, $"emailjobs/{id}", AuthBody(), + LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> ListEmailJobsAsync( + EmailJobFilter? filter = null, int? page = null, CancellationToken cancellationToken = default) + { + var url = BuildListUrl("emailjobs", filter is { } f ? EmailJobFilterToWire(f) : null, page); + + var response = await SendAsync( + HttpMethod.Get, url, AuthBody(), + LetterXpressJsonContext.Default.LxResponseJsonElement, cancellationToken).ConfigureAwait(false); + + try + { + return ParseEmailList(response.Data); + } + catch (JsonException ex) + { + throw new LetterXpressException( + "Failed to parse LetterXpress e-mail job list response.", null, null, RawTextOrNull(response.Data), ex); + } + } + + /// + public async Task> ListTransactionsAsync( + TransactionFilter? filter = null, int? page = null, CancellationToken cancellationToken = default) + { + var url = BuildListUrl("transactions", filter is { } f ? TransactionFilterToWire(f) : null, page); + + var response = await SendAsync( + HttpMethod.Get, url, AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxTransactionsData, cancellationToken).ConfigureAwait(false); + + var data = response.Data; + return new PagedResult + { + Items = data?.Transactions?.Select(MapTransaction).ToList() ?? [], + Pagination = MapPagination(data?.Pagination) + }; + } + + /// + public async Task> ListInvoicesAsync(int? page = null, CancellationToken cancellationToken = default) + { + var url = BuildListUrl("invoices", null, page); + + var response = await SendAsync( + HttpMethod.Get, url, AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxInvoicesData, cancellationToken).ConfigureAwait(false); + + var data = response.Data; + return new PagedResult + { + Items = data?.Invoices?.Select(MapInvoice).ToList() ?? [], + Pagination = MapPagination(data?.Pagination) + }; + } + + /// + public async Task GetInvoiceAsync(long id, CancellationToken cancellationToken = default) + { + var response = await SendAsync( + HttpMethod.Get, $"invoices/{id}", AuthBody(), + LetterXpressJsonContext.Default.LxResponseLxInvoiceWire, cancellationToken).ConfigureAwait(false); + + var data = response.Data ?? throw new LetterXpressException("LetterXpress invoice response contained no data."); + return MapInvoice(data); + } + + // ---- HTTP plumbing -------------------------------------------------- + + private LxAuth BuildAuth() => new() + { + Username = _options.Username, + ApiKey = _options.ApiKey, + Mode = _options.Mode + }; + + private HttpContent AuthBody() => + JsonContent.Create(new LxAuthRequest { Auth = BuildAuth() }, LetterXpressJsonContext.Default.LxAuthRequest); + + private async Task> SendAsync( + HttpMethod method, + string url, + HttpContent content, + System.Text.Json.Serialization.Metadata.JsonTypeInfo> responseInfo, + CancellationToken cancellationToken) + { + // LetterXpress requires the Content-Type to be exactly "application/json". + // JsonContent/StringContent would otherwise append "; charset=utf-8", which the API rejects with HTTP 400. + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using var request = new HttpRequestMessage(method, url) { Content = content }; + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var rawBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var detail = LetterXpressErrorHelper.TryParseErrorDetail(rawBody); + throw new LetterXpressException( + $"LetterXpress API returned {(int)response.StatusCode}: {detail}", + response.StatusCode, + ((int)response.StatusCode).ToString(), + rawBody); + } + + LxResponse? parsed; + try + { + parsed = JsonSerializer.Deserialize(rawBody, responseInfo); + } + catch (JsonException ex) + { + throw new LetterXpressException( + "Failed to deserialize LetterXpress response.", null, null, rawBody, ex); + } + + if (parsed is null) + { + throw new LetterXpressException( + "LetterXpress response was empty.", null, null, rawBody); + } + + // The API also reports application-level errors via the body "status" field, + // which mirrors the documented HTTP status codes (400/401/403/...). + if (parsed.Status is not 0 and not 200) + { + var statusCode = Enum.IsDefined(typeof(HttpStatusCode), parsed.Status) + ? (HttpStatusCode?)parsed.Status + : null; + throw new LetterXpressException( + $"LetterXpress API returned status {parsed.Status}: {parsed.Message}", + statusCode, + parsed.Status.ToString(CultureInfo.InvariantCulture), + rawBody); + } + + return parsed; + } + + private static string BuildListUrl(string path, string? filter, int? page) + { + var query = new List(); + if (filter is not null) query.Add($"filter={Uri.EscapeDataString(filter)}"); + if (page is { } p) query.Add($"page={p.ToString(CultureInfo.InvariantCulture)}"); + return query.Count == 0 ? path : $"{path}?{string.Join('&', query)}"; + } + + // ---- Request building ----------------------------------------------- + + // The API accepts at most 50 MB per PDF / request. + private const long MaxFileBytes = 50L * 1024 * 1024; + + private static void ValidateLetterRequest(LetterRequest request) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.File); + ArgumentNullException.ThrowIfNull(request.Specification); + + if (request.File.Length == 0) + { + throw new ArgumentException("The letter file must not be empty.", nameof(request)); + } + + // The 50 MB limit applies per request, so count the main file plus all secondary PDFs. + long totalBytes = request.File.LongLength; + if (request.Attachments is { } attachments) + { + totalBytes += attachments.Where(a => a is not null).Sum(a => a.LongLength); + } + if (request.Backgrounds is { } bg) + { + totalBytes += (bg.FirstPage?.LongLength ?? 0) + (bg.OtherPages?.LongLength ?? 0); + } + if (request.TermsAndConditions?.Terms is { } terms) + { + totalBytes += terms.LongLength; + } + + if (totalBytes > MaxFileBytes) + { + throw new ArgumentException( + $"The request exceeds the {MaxFileBytes / (1024 * 1024)} MB limit (PDFs total {totalBytes} bytes).", + nameof(request)); + } + + // Per the spec, a serial letter cannot be combined with an email_letter object. + if (request.SerialLetter is not null && request.EmailLetter is not null) + { + throw new ArgumentException( + "SerialLetter and EmailLetter cannot be combined in a single request.", nameof(request)); + } + + // A numeric serial separator must actually be a positive whole number, otherwise the wire + // shape (pages_separator_type = "number") would carry a string value, violating the spec. + if (request.SerialLetter is { PagesSeparatorType: SeparatorType.Number } serial + && (!long.TryParse(serial.PagesSeparatorValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pages) || pages <= 0)) + { + throw new ArgumentException( + "SerialLetter.PagesSeparatorValue must be a positive whole number when PagesSeparatorType is Number.", + nameof(request)); + } + + // Registered mail can only be sent nationally. + if (request.Registered != RegisteredMail.None && request.Specification.Shipping == ShippingType.International) + { + throw new ArgumentException( + "Registered mail can only be sent nationally.", nameof(request)); + } + + // The dispatch date, if set, must not be in the past (the server enforces "in the future"). + if (request.DispatchDate is { } dispatch && dispatch < DateOnly.FromDateTime(DateTime.UtcNow)) + { + throw new ArgumentException("DispatchDate must not be in the past.", nameof(request)); + } + } + + private static LxLetterWire BuildLetterWire(LetterRequest request) + { + var (base64File, checksum) = EncodeFile(request.File); + var spec = request.Specification; + + var wire = new LxLetterWire + { + Base64File = base64File, + Base64FileChecksum = checksum, + Specification = new LxSpecificationWire + { + Color = ColorToWire(spec.Color), + Mode = ModeToWire(spec.Mode), + Shipping = ShippingToWire(spec.Shipping), + C4 = spec.C4 ? 1 : 0 + }, + FilenameOriginal = request.FilenameOriginal, + Registered = RegisteredToWire(request.Registered), + DispatchDate = request.DispatchDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Notice = request.Notice + }; + + if (request.SerialLetter is { } sl) + { + // For separator type "number" the API expects a JSON number, otherwise a JSON string (keyword). + JsonNode separatorValue = + sl.PagesSeparatorType == SeparatorType.Number + && long.TryParse(sl.PagesSeparatorValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pageCount) + ? JsonValue.Create(pageCount) + : JsonValue.Create(sl.PagesSeparatorValue); + + wire.SerialLetter = new LxSerialWire + { + PagesSeparatorType = SeparatorTypeToWire(sl.PagesSeparatorType), + PagesSeparatorValue = separatorValue + }; + } + + if (request.EmailLetter is { } el) + { + wire.EmailLetter = new LxEmailLetterWire + { + EmailOption = EmailOptionToWire(el.EmailOption), + EmailReceiver = el.EmailReceiver + }; + } + + if (request.Attachments is { Count: > 0 } attachments) + { + wire.Base64Attachments = attachments.Select(Convert.ToBase64String).ToList(); + } + + if (request.Backgrounds is { } bg) + { + wire.Backgrounds = new LxBackgroundsWire + { + Base64BackgroundFirstPage = bg.FirstPage is null ? null : Convert.ToBase64String(bg.FirstPage), + Base64BackgroundOtherPages = bg.OtherPages is null ? null : Convert.ToBase64String(bg.OtherPages) + }; + } + + if (request.TermsAndConditions is { } tc) + { + wire.TermsAndConditions = new LxTermsWire + { + Base64Terms = Convert.ToBase64String(tc.Terms), + TermsOnAllPages = tc.OnAllPages ? 1 : 0 + }; + } + + if (request.BankForm is { } bf) + { + wire.BankForm = new LxBankFormWire + { + BankFormIncluded = bf.Included ? 1 : 0, + Payee = bf.Payee, + Iban = bf.Iban, + Bic = bf.Bic, + Amount = bf.Amount, + PurposeOfPayment = bf.PurposeOfPayment, + PurposeOfPayment2 = bf.PurposeOfPayment2 + }; + } + + return wire; + } + + internal static (string Base64File, string Checksum) EncodeFile(byte[] file) + { + var base64File = Convert.ToBase64String(file); + var hash = MD5.HashData(Encoding.UTF8.GetBytes(base64File)); + var checksum = Convert.ToHexString(hash).ToLowerInvariant(); + return (base64File, checksum); + } + + // ---- Response mapping ----------------------------------------------- + + private EmailJobResult ParseEmailCreate(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Array) + { + return new EmailJobResult { EmailJobs = DeserializeEmailJobs(data) }; + } + + if (data.ValueKind == JsonValueKind.Object && data.TryGetProperty("emailjobs", out _)) + { + var combined = data.Deserialize(LetterXpressJsonContext.Default.LxEmailCreateData); + return new EmailJobResult + { + EmailJobs = combined?.EmailJobs?.Select(MapEmailJob).ToList() ?? [], + PrintJobs = combined?.PrintJobs?.Select(MapPrintJob).ToList() ?? [] + }; + } + + if (data.ValueKind == JsonValueKind.Object) + { + var single = data.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire); + return new EmailJobResult { EmailJobs = single is null ? [] : [MapEmailJob(single)] }; + } + + return new EmailJobResult(); + } + + private PagedResult ParseEmailList(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Array) + { + return new PagedResult { Items = DeserializeEmailJobs(data) }; + } + + if (data.ValueKind != JsonValueKind.Object) + { + return new PagedResult(); + } + + Pagination? pagination = null; + if (data.TryGetProperty("pagination", out var paginationElement)) + { + pagination = MapPagination(paginationElement.Deserialize(LetterXpressJsonContext.Default.LxPaginationWire)); + } + + var jobs = new List(); + if (data.TryGetProperty("emailjobs", out var emailJobsElement) && emailJobsElement.ValueKind == JsonValueKind.Array) + { + jobs = DeserializeEmailJobs(emailJobsElement); + } + else if (data.TryGetProperty("id", out _)) + { + // Defensive: a single e-mail job object without an explicit array wrapper. + var single = data.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire); + if (single is not null) jobs.Add(MapEmailJob(single)); + } + + return new PagedResult { Items = jobs, Pagination = pagination }; + } + + private static string? RawTextOrNull(JsonElement element) => + element.ValueKind == JsonValueKind.Undefined ? null : element.GetRawText(); + + private List DeserializeEmailJobs(JsonElement array) + { + var jobs = new List(); + foreach (var element in array.EnumerateArray()) + { + var wire = element.Deserialize(LetterXpressJsonContext.Default.LxEmailJobWire); + if (wire is not null) jobs.Add(MapEmailJob(wire)); + } + return jobs; + } + + private static PrintJob MapPrintJob(LxPrintJobWire w) => new() + { + Id = w.Id, + Shipping = w.Shipping, + Mode = w.Mode, + Color = w.Color, + C4 = w.C4 != 0, + Registered = w.Registered, + BankForm = w.BankForm != 0, + Notice = w.Notice, + Status = w.Status, + DispatchDate = w.DispatchDate, + FilenameOriginal = w.FilenameOriginal, + CreatedAt = ParseDateTime(w.CreatedAt), + UpdatedAt = ParseDateTime(w.UpdatedAt), + Items = w.Items?.Select(MapPrintJobItem).ToList() ?? [] + }; + + private static PrintJobItem MapPrintJobItem(LxPrintJobItemWire w) => new() + { + Address = w.Address, + Pages = w.Pages, + Amount = w.Amount, + Vat = w.Vat, + Status = w.Status, + TrackingCode = w.TrackingCode, + Base64Data = w.Base64Data + }; + + private static EmailJob MapEmailJob(LxEmailJobWire w) => new() + { + Id = w.Id, + EmailSender = w.EmailSender, + EmailReceiver = w.EmailReceiver, + EmailOption = w.EmailOption, + SentAt = ParseDateTime(w.SentAt), + Amount = w.Amount, + Vat = w.Vat, + Status = w.Status, + Subject = w.Subject, + Content = w.Content, + Footer = w.Footer, + CreatedAt = ParseDateTime(w.CreatedAt), + PrintJobId = w.PrintjobId, + PrintJob = w.Printjob is null ? null : MapPrintJob(w.Printjob) + }; + + private static Transaction MapTransaction(LxTransactionWire w) => new() + { + Amount = w.Amount, + Currency = w.Currency, + Description = w.Description, + CreatedAt = ParseDateTime(w.CreatedAt) + }; + + private static Invoice MapInvoice(LxInvoiceWire w) => new() + { + Id = w.Id, + Amount = w.Amount, + Vat = w.Vat, + InvoiceDate = ParseDate(w.InvoiceDate), + Base64Data = w.Base64Data + }; + + private static Pagination? MapPagination(LxPaginationWire? w) => w is null ? null : new Pagination + { + Total = w.Total, + Count = w.Count, + CurrentPage = w.CurrentPage, + LastPage = w.LastPage, + PerPage = w.PerPage, + FirstPageUrl = w.FirstPageUrl, + LastPageUrl = w.LastPageUrl, + NextPageUrl = w.NextPageUrl, + PrevPageUrl = w.PrevPageUrl + }; + + // LetterXpress returns timestamps without a timezone designator, in German local time + // (Europe/Berlin, i.e. CET/CEST). Verified against the live API. + private static readonly TimeZoneInfo? BerlinTimeZone = ResolveBerlinTimeZone(); + + private static TimeZoneInfo? ResolveBerlinTimeZone() + { + foreach (var id in (string[])["Europe/Berlin", "W. Europe Standard Time"]) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch (TimeZoneNotFoundException) + { + // Try the next identifier. + } + catch (InvalidTimeZoneException) + { + // Try the next identifier. + } + } + + return null; + } + + private static DateTimeOffset? ParseDateTime(string? value) + { + if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + return null; + } + + var unspecified = DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); + var offset = BerlinTimeZone?.GetUtcOffset(unspecified) ?? TimeSpan.Zero; + return new DateTimeOffset(unspecified, offset); + } + + private static DateOnly? ParseDate(string? value) => + DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + ? parsed + : null; + + // ---- Enum mapping --------------------------------------------------- + + private static string ColorToWire(LetterColor color) => color switch + { + LetterColor.BlackWhite => "1", + LetterColor.Color => "4", + _ => "1" + }; + + private static string ModeToWire(PrintMode mode) => mode switch + { + PrintMode.Simplex => "simplex", + PrintMode.Duplex => "duplex", + _ => "simplex" + }; + + private static string ShippingToWire(ShippingType shipping) => shipping switch + { + ShippingType.National => "national", + ShippingType.International => "international", + ShippingType.Auto => "auto", + _ => "auto" + }; + + private static string? RegisteredToWire(RegisteredMail registered) => registered switch + { + RegisteredMail.EinschreibenEinwurf => "r1", + RegisteredMail.Einschreiben => "r2", + _ => null + }; + + private static string EmailOptionToWire(EmailOption option) => option switch + { + EmailOption.MailDirect => "maildirect", + EmailOption.MailPlus => "mailplus", + EmailOption.MailSecure => "mailsecure", + _ => "maildirect" + }; + + private static string SeparatorTypeToWire(SeparatorType type) => type switch + { + SeparatorType.String => "string", + SeparatorType.Number => "number", + _ => "string" + }; + + private static string PrintJobFilterToWire(PrintJobFilter filter) => filter switch + { + PrintJobFilter.Queue => "queue", + PrintJobFilter.Hold => "hold", + PrintJobFilter.Done => "done", + PrintJobFilter.Canceled => "canceled", + PrintJobFilter.Draft => "draft", + _ => "queue" + }; + + private static string EmailJobFilterToWire(EmailJobFilter filter) => filter switch + { + EmailJobFilter.Queue => "queue", + EmailJobFilter.Hold => "hold", + EmailJobFilter.Canceled => "canceled", + EmailJobFilter.Draft => "draft", + EmailJobFilter.Success => "success", + _ => "queue" + }; + + private static string TransactionFilterToWire(TransactionFilter filter) => filter switch + { + TransactionFilter.PayIns => "payins", + TransactionFilter.PayOuts => "payouts", + TransactionFilter.PrintJobs => "printjobs", + _ => "payins" + }; +} diff --git a/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs b/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs new file mode 100644 index 0000000..9d0eebc --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/LetterXpressLettersExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Parcel.NET.LetterXpress.Letters; + +/// +/// Extension methods for registering the LetterXpress letters client. +/// +public static class LetterXpressLettersExtensions +{ + /// + /// Adds the LetterXpress letters client to the service collection, registered as + /// . + /// + /// The LetterXpress builder. + /// The builder for chaining. + public static LetterXpressBuilder AddLetterXpressLetters(this LetterXpressBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); + + builder.Services.AddTransient(sp => sp.GetRequiredService()); + + return builder; + } +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs b/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs new file mode 100644 index 0000000..f7f5cb4 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/AccountModels.cs @@ -0,0 +1,155 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// Account balance. +/// +public class Balance +{ + /// + /// Gets the current balance. + /// + public decimal Amount { get; init; } + + /// + /// Gets the currency (e.g. EUR). + /// + public string? Currency { get; init; } +} + +/// +/// Result of a price calculation. +/// +public class PriceResult +{ + /// + /// Gets the calculated gross price. + /// + public decimal Price { get; init; } + + /// + /// Gets the number of pages the price was calculated for. + /// + public int? Pages { get; init; } + + /// + /// Gets the print color the price was calculated for. + /// + public string? Color { get; init; } + + /// + /// Gets the print mode the price was calculated for. + /// + public string? Mode { get; init; } + + /// + /// Gets the shipping destination the price was calculated for. + /// + public string? Shipping { get; init; } +} + +/// +/// An account transaction. +/// +public class Transaction +{ + /// + /// Gets the amount (negative for charges, positive for top-ups). + /// + public decimal Amount { get; init; } + + /// + /// Gets the currency (e.g. EUR). + /// + public string? Currency { get; init; } + + /// + /// Gets the description. + /// + public string? Description { get; init; } + + /// + /// Gets the creation timestamp. + /// + public DateTimeOffset? CreatedAt { get; init; } +} + +/// +/// An invoice. +/// +public class Invoice +{ + /// + /// Gets the invoice ID. + /// + public long Id { get; init; } + + /// + /// Gets the net amount. + /// + public decimal Amount { get; init; } + + /// + /// Gets the VAT. + /// + public decimal Vat { get; init; } + + /// + /// Gets the invoice date. + /// + public DateOnly? InvoiceDate { get; init; } + + /// + /// Gets the invoice PDF as base64, returned only when retrieving a single invoice. + /// + public string? Base64Data { get; init; } +} + +/// +/// Pagination metadata returned by list endpoints. +/// +public class Pagination +{ + /// Gets the total number of items. + public int Total { get; init; } + + /// Gets the number of items on the current page. + public int Count { get; init; } + + /// Gets the current page number. + public int CurrentPage { get; init; } + + /// Gets the last page number. + public int LastPage { get; init; } + + /// Gets the number of items per page. + public int PerPage { get; init; } + + /// Gets the URL of the first page, if any. + public string? FirstPageUrl { get; init; } + + /// Gets the URL of the last page, if any. + public string? LastPageUrl { get; init; } + + /// Gets the URL of the next page, if any. + public string? NextPageUrl { get; init; } + + /// Gets the URL of the previous page, if any. + public string? PrevPageUrl { get; init; } +} + +/// +/// A paged list result. +/// +/// The item type. +public class PagedResult +{ + /// + /// Gets the items on the current page. + /// + public IReadOnlyList Items { get; init; } = []; + + /// + /// Gets the pagination metadata, if available. + /// + public Pagination? Pagination { get; init; } +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs b/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs new file mode 100644 index 0000000..9ca689f --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/EmailJob.cs @@ -0,0 +1,94 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// A LetterXpress SMART@MAIL e-mail job. +/// +public class EmailJob +{ + /// + /// Gets the e-mail job ID. + /// + public long Id { get; init; } + + /// + /// Gets the sender e-mail address. + /// + public string? EmailSender { get; init; } + + /// + /// Gets the recipient e-mail address. + /// + public string? EmailReceiver { get; init; } + + /// + /// Gets the SMART@MAIL option (maildirect, mailplus, mailsecure). + /// + public string? EmailOption { get; init; } + + /// + /// Gets the timestamp the e-mail was sent, if available. + /// + public DateTimeOffset? SentAt { get; init; } + + /// + /// Gets the net amount charged. + /// + public decimal Amount { get; init; } + + /// + /// Gets the VAT. + /// + public decimal Vat { get; init; } + + /// + /// Gets the job status (e.g. queue, hold, success). + /// + public string? Status { get; init; } + + /// + /// Gets the e-mail subject. + /// + public string? Subject { get; init; } + + /// + /// Gets the e-mail content. + /// + public string? Content { get; init; } + + /// + /// Gets the e-mail footer. + /// + public string? Footer { get; init; } + + /// + /// Gets the creation timestamp. + /// + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets the ID of the associated print job, when the e-mail job is part of a serial response. + /// + public long? PrintJobId { get; init; } + + /// + /// Gets the associated print job, returned for MailPlus jobs. + /// + public PrintJob? PrintJob { get; init; } +} + +/// +/// Result of submitting an e-mail job. Depending on the request this contains a single e-mail job, +/// multiple e-mail jobs (serial processing), and/or print jobs created for pages without a white code. +/// +public class EmailJobResult +{ + /// + /// Gets the created e-mail jobs. + /// + public IReadOnlyList EmailJobs { get; init; } = []; + + /// + /// Gets any print jobs created alongside the e-mail jobs (e.g. for MailPlus or pages without a white code). + /// + public IReadOnlyList PrintJobs { get; init; } = []; +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs b/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs new file mode 100644 index 0000000..f744b66 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/Enums.cs @@ -0,0 +1,140 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// Print color of a letter. +/// +public enum LetterColor +{ + /// Black/white printing (API value "1"). + BlackWhite, + + /// Color printing (API value "4"). + Color +} + +/// +/// Print mode of a letter. +/// +public enum PrintMode +{ + /// Single-sided printing (API value "simplex"). + Simplex, + + /// Double-sided printing (API value "duplex"). + Duplex +} + +/// +/// Shipping destination of a letter. +/// +public enum ShippingType +{ + /// National shipping (API value "national"). + National, + + /// International shipping (API value "international"). + International, + + /// Automatic detection (API value "auto"). Not valid for price requests. + Auto +} + +/// +/// Registered mail option. Registered mail can only be sent nationally. +/// +public enum RegisteredMail +{ + /// No registered mail. + None, + + /// Einschreiben Einwurf (API value "r1"). + EinschreibenEinwurf, + + /// Einschreiben (API value "r2"). + Einschreiben +} + +/// +/// LXP SMART@MAIL e-mail delivery option. +/// +public enum EmailOption +{ + /// maildirect. + MailDirect, + + /// mailplus. + MailPlus, + + /// mailsecure. + MailSecure +} + +/// +/// Type of the serial letter page separator. +/// +public enum SeparatorType +{ + /// A keyword string is used to separate the individual letters (API value "string"). + String, + + /// A fixed number of pages is used to separate the individual letters (API value "number"). + Number +} + +/// +/// Filter for listing print jobs. +/// +public enum PrintJobFilter +{ + /// Jobs in the queue. + Queue, + + /// Held jobs. + Hold, + + /// Processed jobs. + Done, + + /// Cancelled jobs. + Canceled, + + /// Jobs in the Postbox. + Draft +} + +/// +/// Filter for listing e-mail jobs. Accepted values were verified against the live API +/// (the e-mail equivalent of the print job's done status is success). +/// +public enum EmailJobFilter +{ + /// Jobs in the queue. + Queue, + + /// Held jobs. + Hold, + + /// Cancelled jobs. + Canceled, + + /// Jobs in the Postbox. + Draft, + + /// Successfully sent jobs. + Success +} + +/// +/// Filter for listing transactions. +/// +public enum TransactionFilter +{ + /// Pay-ins / vouchers / kickbacks. + PayIns, + + /// Pay-outs. + PayOuts, + + /// Print jobs. + PrintJobs +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs new file mode 100644 index 0000000..cdc4a30 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterOptions.cs @@ -0,0 +1,112 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// Options for splitting a serial letter into individual letters. +/// +public class SerialLetterOptions +{ + /// + /// Gets or sets the separator type. + /// + public SeparatorType PagesSeparatorType { get; set; } + + /// + /// Gets or sets the separator value: a keyword when is + /// , or a page count when it is . + /// + public required string PagesSeparatorValue { get; set; } +} + +/// +/// LXP SMART@MAIL options for transmitting a single job as an e-mail letter. +/// Cannot be combined with a serial letter. +/// +public class EmailLetterOptions +{ + /// + /// Gets or sets the SMART@MAIL delivery option. + /// + public EmailOption EmailOption { get; set; } + + /// + /// Gets or sets the valid recipient e-mail address. + /// + public required string EmailReceiver { get; set; } +} + +/// +/// Background PDFs inserted behind a letter's pages. Each background must be a single-page +/// A4 portrait (29.7 x 21.0 cm) PDF. +/// +public class LetterBackgrounds +{ + /// + /// Gets or sets the background PDF for the first page. + /// + public byte[]? FirstPage { get; set; } + + /// + /// Gets or sets the background PDF for all other pages. + /// + public byte[]? OtherPages { get; set; } +} + +/// +/// Terms and conditions PDF appended to a letter. Must be a single-page A4 portrait +/// (29.7 x 21.0 cm) PDF. Printing is automatically double-sided. +/// +public class TermsAndConditions +{ + /// + /// Gets or sets the terms and conditions PDF. + /// + public required byte[] Terms { get; set; } + + /// + /// Gets or sets a value indicating whether the terms are inserted behind all pages + /// () or only behind the first page (). + /// + public bool OnAllPages { get; set; } +} + +/// +/// Bank transfer form attached as the last sheet of a letter. +/// +public class BankForm +{ + /// + /// Gets or sets a value indicating whether the bank form is already included in the PDF's last sheet. + /// When , the payment detail fields below are ignored and the last (blank) sheet is used. + /// + public bool Included { get; set; } + + /// + /// Gets or sets the payee. Max. 27 characters. Ignored when is . + /// + public string? Payee { get; set; } + + /// + /// Gets or sets the IBAN. Max. 34 characters. + /// + public string? Iban { get; set; } + + /// + /// Gets or sets the BIC. Max. 11 characters. + /// + public string? Bic { get; set; } + + /// + /// Gets or sets the amount. Max. 12 characters. + /// + public string? Amount { get; set; } + + /// + /// Gets or sets the purpose of payment. Max. 27 characters. + /// + public string? PurposeOfPayment { get; set; } + + /// + /// Gets or sets the second purpose of payment line. Max. 27 characters. + /// + public string? PurposeOfPayment2 { get; set; } +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs new file mode 100644 index 0000000..10346d1 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterRequest.cs @@ -0,0 +1,156 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// Request for submitting a letter (print job or e-mail job). The PDF is supplied as raw bytes; +/// the client computes the base64 encoding and MD5 checksum automatically. +/// +public class LetterRequest +{ + /// + /// Gets or sets the letter PDF as raw bytes. Max. 50 MB. + /// + public required byte[] File { get; set; } + + /// + /// Gets or sets the print and shipping specification. + /// + public required LetterSpecification Specification { get; set; } + + /// + /// Gets or sets the original file name (e.g. Rechnung-123456.pdf). + /// Optional; necessary for LXP SMART@MAIL and returned when retrieving jobs. + /// + public string? FilenameOriginal { get; set; } + + /// + /// Gets or sets the registered mail option. Registered mail can only be sent nationally. + /// Defaults to . + /// + public RegisteredMail Registered { get; set; } = RegisteredMail.None; + + /// + /// Gets or sets the dispatch date. Optional; must be in the future. + /// + public DateOnly? DispatchDate { get; set; } + + /// + /// Gets or sets serial letter options. Cannot be combined with . + /// + public SerialLetterOptions? SerialLetter { get; set; } + + /// + /// Gets or sets LXP SMART@MAIL e-mail options for a single job. Cannot be combined with . + /// + public EmailLetterOptions? EmailLetter { get; set; } + + /// + /// Gets or sets additional attachments as raw PDF bytes. Each attachment must be A4 portrait + /// (29.7 x 21.0 cm). The order is preserved. + /// + public IReadOnlyList? Attachments { get; set; } + + /// + /// Gets or sets the letter background PDFs. + /// + public LetterBackgrounds? Backgrounds { get; set; } + + /// + /// Gets or sets the terms and conditions PDF. + /// + public TermsAndConditions? TermsAndConditions { get; set; } + + /// + /// Gets or sets the bank transfer form. + /// + public BankForm? BankForm { get; set; } + + /// + /// Gets or sets an arbitrary notice stored with the job. Max. 255 characters. + /// + public string? Notice { get; set; } +} + +/// +/// Request for changing an existing print job within the first 15 minutes after submission. +/// Only the specification and shipping options can be changed; the PDF cannot. +/// All properties are optional — only set ones are sent. +/// +public class PrintJobUpdate +{ + /// + /// Gets or sets the new dispatch date. + /// + public DateOnly? DispatchDate { get; set; } + + /// + /// Gets or sets the new registered mail option. + /// + public RegisteredMail? Registered { get; set; } + + /// + /// Gets or sets the new notice. + /// + public string? Notice { get; set; } + + /// + /// Gets or sets the new print color. + /// + public LetterColor? Color { get; set; } + + /// + /// Gets or sets the new print mode. + /// + public PrintMode? Mode { get; set; } + + /// + /// Gets or sets the new shipping destination. + /// + public ShippingType? Shipping { get; set; } + + /// + /// Gets or sets the new C4 envelope flag. + /// + public bool? C4 { get; set; } +} + +/// +/// Request for a price calculation. +/// +public class PriceRequest +{ + /// + /// Gets or sets the number of pages. + /// + public required int Pages { get; set; } + + /// + /// Gets or sets the print color. Defaults to . + /// + public LetterColor Color { get; set; } = LetterColor.BlackWhite; + + /// + /// Gets or sets the print mode. Defaults to . + /// + public PrintMode Mode { get; set; } = PrintMode.Simplex; + + /// + /// Gets or sets the shipping destination. Only and + /// are valid for price requests. + /// + public ShippingType Shipping { get; set; } = ShippingType.National; + + /// + /// Gets or sets a value indicating whether to use a C4 envelope. + /// + public bool C4 { get; set; } + + /// + /// Gets or sets the SMART@MAIL e-mail option. Optional. + /// + public EmailOption? EmailOption { get; set; } + + /// + /// Gets or sets the registered mail option. Optional; only national shipping is supported for registered mail. + /// + public RegisteredMail Registered { get; set; } = RegisteredMail.None; +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs b/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs new file mode 100644 index 0000000..00bed4d --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/LetterSpecification.cs @@ -0,0 +1,28 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// Print and shipping specification of a letter. +/// +public class LetterSpecification +{ + /// + /// Gets or sets the print color. Defaults to . + /// + public LetterColor Color { get; set; } = LetterColor.BlackWhite; + + /// + /// Gets or sets the print mode. Defaults to . + /// + public PrintMode Mode { get; set; } = PrintMode.Simplex; + + /// + /// Gets or sets the shipping destination. Defaults to . + /// + public ShippingType Shipping { get; set; } = ShippingType.Auto; + + /// + /// Gets or sets a value indicating whether to use a C4 envelope. + /// Optional, applies to letters under 9 sheets. + /// + public bool C4 { get; set; } +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs b/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs new file mode 100644 index 0000000..f9829fe --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Models/PrintJob.cs @@ -0,0 +1,118 @@ +namespace Parcel.NET.LetterXpress.Letters.Models; + +/// +/// A single recipient item of a print job. +/// +public class PrintJobItem +{ + /// + /// Gets the recipient address as a single line. + /// + public string? Address { get; init; } + + /// + /// Gets the number of pages. + /// + public int Pages { get; init; } + + /// + /// Gets the net amount charged for this item. + /// + public decimal Amount { get; init; } + + /// + /// Gets the VAT for this item. + /// + public decimal Vat { get; init; } + + /// + /// Gets the item status (e.g. queue, sent). + /// + public string? Status { get; init; } + + /// + /// Gets the registered mail tracking code, if available. + /// + public string? TrackingCode { get; init; } + + /// + /// Gets the rendered letter PDF as base64, returned only when retrieving a single job. + /// + public string? Base64Data { get; init; } +} + +/// +/// A LetterXpress print job. +/// +public class PrintJob +{ + /// + /// Gets the print job ID. + /// + public long Id { get; init; } + + /// + /// Gets the shipping destination (e.g. national). + /// + public string? Shipping { get; init; } + + /// + /// Gets the print mode (e.g. simplex). + /// + public string? Mode { get; init; } + + /// + /// Gets the print color (1 for black/white, 4 for color). + /// + public string? Color { get; init; } + + /// + /// Gets a value indicating whether a C4 envelope is used. + /// + public bool C4 { get; init; } + + /// + /// Gets the registered mail option (r1, r2), if any. + /// + public string? Registered { get; init; } + + /// + /// Gets a value indicating whether a bank form is attached. + /// + public bool BankForm { get; init; } + + /// + /// Gets the notice stored with the job. + /// + public string? Notice { get; init; } + + /// + /// Gets the job status (e.g. queue, hold, done, canceled, draft). + /// + public string? Status { get; init; } + + /// + /// Gets the dispatch date, if set. + /// + public string? DispatchDate { get; init; } + + /// + /// Gets the original file name, if provided. + /// + public string? FilenameOriginal { get; init; } + + /// + /// Gets the creation timestamp. The API reports times in German local time (Europe/Berlin). + /// + public DateTimeOffset? CreatedAt { get; init; } + + /// + /// Gets the last update timestamp. The API reports times in German local time (Europe/Berlin). + /// + public DateTimeOffset? UpdatedAt { get; init; } + + /// + /// Gets the recipient items of the job. + /// + public IReadOnlyList Items { get; init; } = []; +} diff --git a/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj b/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj new file mode 100644 index 0000000..9c19692 --- /dev/null +++ b/src/Parcel.NET.LetterXpress.Letters/Parcel.NET.LetterXpress.Letters.csproj @@ -0,0 +1,18 @@ + + + + Parcel.NET.LetterXpress.Letters + LetterXpress (A&O Fischer) API v3 client for Parcel.NET — print jobs, SMART@MAIL e-mail jobs, balance, price, transactions, and invoices. + letter;mail;post;smartmail;logistics;letterxpress + + + + + + + + + + + + diff --git a/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs b/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs new file mode 100644 index 0000000..035c884 --- /dev/null +++ b/src/Parcel.NET.LetterXpress/Internal/LetterXpressErrorHelper.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +namespace Parcel.NET.LetterXpress.Internal; + +internal static class LetterXpressErrorHelper +{ + internal static string TryParseErrorDetail(string rawBody) + { + if (string.IsNullOrWhiteSpace(rawBody)) + { + return rawBody; + } + + try + { + using var doc = JsonDocument.Parse(rawBody); + if (doc.RootElement.TryGetProperty("message", out var message)) + { + return message.GetString() ?? rawBody; + } + if (doc.RootElement.TryGetProperty("error", out var error)) + { + return error.GetString() ?? rawBody; + } + } + catch (JsonException) + { + // Fall through - raw body is not valid JSON + } + + return rawBody; + } +} diff --git a/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs b/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs new file mode 100644 index 0000000..4fd45bb --- /dev/null +++ b/src/Parcel.NET.LetterXpress/LetterXpressBuilder.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Parcel.NET.LetterXpress; + +/// +/// Builder for chaining LetterXpress sub-service registrations after calling +/// . +/// +public class LetterXpressBuilder +{ + /// + /// Gets the underlying service collection. + /// + public IServiceCollection Services { get; } + + internal LetterXpressBuilder(IServiceCollection services) + { + Services = services; + } +} diff --git a/src/Parcel.NET.LetterXpress/LetterXpressException.cs b/src/Parcel.NET.LetterXpress/LetterXpressException.cs new file mode 100644 index 0000000..d635515 --- /dev/null +++ b/src/Parcel.NET.LetterXpress/LetterXpressException.cs @@ -0,0 +1,32 @@ +using System.Net; +using Parcel.NET.Abstractions.Exceptions; + +namespace Parcel.NET.LetterXpress; + +/// +/// Exception thrown when a LetterXpress API operation fails. +/// +public class LetterXpressException : ParcelException +{ + /// + /// Initializes a new instance of . + /// + public LetterXpressException(string message) : base(message) { } + + /// + /// Initializes a new instance of with an inner exception. + /// + public LetterXpressException(string message, Exception innerException) + : base(message, innerException) { } + + /// + /// Initializes a new instance of with carrier API error details. + /// + public LetterXpressException( + string message, + HttpStatusCode? statusCode, + string? errorCode, + string? rawResponse, + Exception? innerException = null) + : base(message, statusCode, errorCode, rawResponse, innerException) { } +} diff --git a/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs b/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs new file mode 100644 index 0000000..353f968 --- /dev/null +++ b/src/Parcel.NET.LetterXpress/LetterXpressOptions.cs @@ -0,0 +1,50 @@ +namespace Parcel.NET.LetterXpress; + +/// +/// Configuration options for the LetterXpress (A&O Fischer) API v3 integration. +/// +public class LetterXpressOptions +{ + private const string ProductionBaseUrl = "https://api.letterxpress.de/v3/"; + + /// + /// Gets or sets the LetterXpress account username. + /// Generated under "Mein Konto > Zugangsdaten > LXP API". + /// + public required string Username { get; set; } + + /// + /// Gets or sets the LetterXpress API key. + /// Generated under "Mein Konto > Zugangsdaten > LXP API". + /// + public required string ApiKey { get; set; } + + /// + /// Gets or sets a value indicating whether to use the LetterXpress test mode. + /// In test mode ("mode": "test") submitted jobs are not processed but stored in the + /// "Postbox" where they can be reviewed, deleted, or sent. Postbox jobs are deleted after 7 days. + /// When , jobs are processed directly ("mode": "live"). + /// + public bool UseTestMode { get; set; } + + /// + /// Gets or sets a custom base URL override. + /// When set, this takes precedence over the default production URL. + /// + public string? CustomBaseUrl { get; set; } + + /// + /// Gets the effective API base URL. Uses if set, + /// otherwise the production URL. A trailing slash is appended if missing so that + /// relative request URIs (e.g. balance, printjobs) resolve correctly. + /// + public string BaseUrl => EnsureTrailingSlash(CustomBaseUrl) ?? ProductionBaseUrl; + + private static string? EnsureTrailingSlash(string? url) => + url is null ? null : url.EndsWith('/') ? url : url + "/"; + + /// + /// Gets the mode value sent in the request auth object (test or live). + /// + internal string Mode => UseTestMode ? "test" : "live"; +} diff --git a/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs b/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs new file mode 100644 index 0000000..095dd92 --- /dev/null +++ b/src/Parcel.NET.LetterXpress/LetterXpressServiceCollectionExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Parcel.NET.LetterXpress; + +/// +/// Extension methods for registering LetterXpress services with the dependency injection container. +/// +public static class LetterXpressServiceCollectionExtensions +{ + /// + /// Adds LetterXpress core services including configuration validation. + /// + /// The service collection. + /// Action to configure . + /// A for chaining LetterXpress sub-service registrations. + public static LetterXpressBuilder AddLetterXpress(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services.AddOptionsWithValidateOnStart() + .Configure(configure) + .Validate(o => !string.IsNullOrWhiteSpace(o.Username), "LetterXpress Username is required.") + .Validate(o => !string.IsNullOrWhiteSpace(o.ApiKey), "LetterXpress ApiKey is required.") + .Validate(o => IsHttpsOrLocalhost(o.CustomBaseUrl), "CustomBaseUrl must use HTTPS."); + + return new LetterXpressBuilder(services); + } + + private static bool IsHttpsOrLocalhost(string? url) + { + if (url is null) return true; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; + if (uri.Scheme == Uri.UriSchemeHttps) return true; + + var host = uri.Host; + return host is "localhost" or "127.0.0.1" or "::1"; + } +} diff --git a/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj b/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj new file mode 100644 index 0000000..5f23cf9 --- /dev/null +++ b/src/Parcel.NET.LetterXpress/Parcel.NET.LetterXpress.csproj @@ -0,0 +1,23 @@ + + + + Parcel.NET.LetterXpress + LetterXpress (A&O Fischer) authentication, shared configuration, and DI extensions for Parcel.NET. + letter;mail;post;logistics;letterxpress;authentication + + + + + + + + + + + + + + + + + diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs new file mode 100644 index 0000000..b76b6b5 --- /dev/null +++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientCoverageTests.cs @@ -0,0 +1,355 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Parcel.NET.LetterXpress.Letters.Models; +using Shouldly; +using Xunit; + +namespace Parcel.NET.LetterXpress.Letters.Tests; + +public class LetterXpressClientCoverageTests +{ + private static readonly byte[] SamplePdf = Encoding.UTF8.GetBytes("%PDF-1.4 sample"); + + private static (LetterXpressClient Client, MockHttpMessageHandler Handler) CreateClient( + string responseBody, HttpStatusCode status = HttpStatusCode.OK) + { + var handler = new MockHttpMessageHandler(responseBody, status); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.letterxpress.de/v3/") }; + var options = Options.Create(new LetterXpressOptions { Username = "u", ApiKey = "k" }); + return (new LetterXpressClient(httpClient, options), handler); + } + + private static JsonElement BodyRoot(MockHttpMessageHandler handler) => + JsonDocument.Parse(handler.LastRequestBody!).RootElement; + + private static LetterRequest BasicLetter() => new() + { + File = SamplePdf, + Specification = new LetterSpecification { Shipping = ShippingType.National } + }; + + private const string OkPrintJob = """{"status":200,"message":"OK","data":{"id":1,"status":"queue"}}"""; + + // ---- Validation ----------------------------------------------------- + + [Fact] + public async Task CreatePrintJobAsync_EmptyFile_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = new LetterRequest { File = [], Specification = new LetterSpecification() }; + + await Should.ThrowAsync(() => client.CreatePrintJobAsync(req)); + } + + [Fact] + public async Task CreateEmailJobAsync_SerialAndEmailCombined_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "2" }; + req.EmailLetter = new EmailLetterOptions { EmailOption = EmailOption.MailDirect, EmailReceiver = "a@x.de" }; + + await Should.ThrowAsync(() => client.CreateEmailJobAsync(req)); + } + + [Fact] + public async Task CreatePrintJobAsync_RegisteredInternational_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification { Shipping = ShippingType.International }, + Registered = RegisteredMail.Einschreiben + }; + + await Should.ThrowAsync(() => client.CreatePrintJobAsync(req)); + } + + [Fact] + public async Task GetPriceAsync_AutoShipping_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + + await Should.ThrowAsync(() => + client.GetPriceAsync(new PriceRequest { Pages = 1, Shipping = ShippingType.Auto })); + } + + [Fact] + public async Task GetPriceAsync_NonPositivePages_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + + await Should.ThrowAsync(() => + client.GetPriceAsync(new PriceRequest { Pages = 0, Shipping = ShippingType.National })); + } + + [Fact] + public async Task CreatePrintJobAsync_SerialNumberWithNonNumericValue_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "abc" }; + + await Should.ThrowAsync(() => client.CreatePrintJobAsync(req)); + } + + [Fact] + public async Task CreatePrintJobAsync_PastDispatchDate_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.DispatchDate = new DateOnly(2000, 1, 1); + + await Should.ThrowAsync(() => client.CreatePrintJobAsync(req)); + } + + [Fact] + public async Task CreatePrintJobAsync_OversizedAttachmentsTotal_Throws() + { + var (client, _) = CreateClient(OkPrintJob); + var req = BasicLetter(); + // Main file is tiny, but an attachment pushes the request over the 50 MB limit. + req.Attachments = [new byte[51 * 1024 * 1024]]; + + await Should.ThrowAsync(() => client.CreatePrintJobAsync(req)); + } + + // ---- Serial letter separator serialization -------------------------- + + [Fact] + public async Task CreatePrintJobAsync_SerialNumber_SerializesAsJsonNumber() + { + var (client, handler) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.Number, PagesSeparatorValue = "3" }; + + await client.CreatePrintJobAsync(req); + + var serial = BodyRoot(handler).GetProperty("letter").GetProperty("serial_letter"); + serial.GetProperty("pages_separator_type").GetString().ShouldBe("number"); + var value = serial.GetProperty("pages_separator_value"); + value.ValueKind.ShouldBe(JsonValueKind.Number); + value.GetInt32().ShouldBe(3); + } + + [Fact] + public async Task CreatePrintJobAsync_SerialString_SerializesAsJsonString() + { + var (client, handler) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.SerialLetter = new SerialLetterOptions { PagesSeparatorType = SeparatorType.String, PagesSeparatorValue = "TRENN" }; + + await client.CreatePrintJobAsync(req); + + var value = BodyRoot(handler).GetProperty("letter").GetProperty("serial_letter").GetProperty("pages_separator_value"); + value.ValueKind.ShouldBe(JsonValueKind.String); + value.GetString().ShouldBe("TRENN"); + } + + // ---- Complex payload building --------------------------------------- + + [Fact] + public async Task CreatePrintJobAsync_AttachmentsAndBankForm_Serialized() + { + var (client, handler) = CreateClient(OkPrintJob); + var req = BasicLetter(); + req.Attachments = [Encoding.UTF8.GetBytes("att1")]; + req.BankForm = new BankForm { Included = false, Iban = "DE0001", Amount = "12,00" }; + + await client.CreatePrintJobAsync(req); + + var letter = BodyRoot(handler).GetProperty("letter"); + var attachments = letter.GetProperty("base64_attachments"); + attachments.GetArrayLength().ShouldBe(1); + attachments[0].GetString().ShouldBe(Convert.ToBase64String(Encoding.UTF8.GetBytes("att1"))); + + var bank = letter.GetProperty("bank_form"); + bank.GetProperty("bank_form_included").GetInt32().ShouldBe(0); + bank.GetProperty("iban").GetString().ShouldBe("DE0001"); + } + + // ---- Update partial-spec -------------------------------------------- + + [Fact] + public async Task UpdatePrintJobAsync_NoticeOnly_OmitsSpecification() + { + var (client, handler) = CreateClient(OkPrintJob); + + await client.UpdatePrintJobAsync(1, new PrintJobUpdate { Notice = "only notice" }); + + var letter = BodyRoot(handler).GetProperty("letter"); + letter.GetProperty("notice").GetString().ShouldBe("only notice"); + letter.TryGetProperty("specification", out _).ShouldBeFalse(); + letter.TryGetProperty("dispatch_date", out _).ShouldBeFalse(); + letter.TryGetProperty("registered", out _).ShouldBeFalse(); + } + + [Fact] + public async Task UpdatePrintJobAsync_ColorOnly_SendsPartialSpecification() + { + var (client, handler) = CreateClient(OkPrintJob); + + await client.UpdatePrintJobAsync(1, new PrintJobUpdate { Color = LetterColor.Color }); + + var spec = BodyRoot(handler).GetProperty("letter").GetProperty("specification"); + spec.GetProperty("color").GetString().ShouldBe("4"); + spec.TryGetProperty("mode", out _).ShouldBeFalse(); + spec.TryGetProperty("shipping", out _).ShouldBeFalse(); + spec.TryGetProperty("c4", out _).ShouldBeFalse(); + } + + // ---- Body-level error ----------------------------------------------- + + [Fact] + public async Task SendAsync_BodyLevelStatusError_ThrowsWithStatusCode() + { + // HTTP 200 but the body reports an application-level error. + const string json = """{"status":400,"message":"Bad Request"}"""; + var (client, _) = CreateClient(json, HttpStatusCode.OK); + + var ex = await Should.ThrowAsync(() => client.GetBalanceAsync()); + + ex.ErrorCode.ShouldBe("400"); + ex.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + ex.RawResponse.ShouldNotBeNull(); + ex.RawResponse!.ShouldContain("Bad Request"); + } + + // ---- Missing endpoint coverage -------------------------------------- + + [Fact] + public async Task GetPrintJobAsync_ReturnsRenderedPdf() + { + const string json = """ + {"status":200,"message":"OK","data":{"id":99,"status":"done","c4":0,"bank_form":0,"items":[{"address":"X","pages":1,"amount":0.67,"vat":0.13,"status":"sent","tracking_code":"RC1DE","base64_data":"QUJD"}]}} + """; + var (client, handler) = CreateClient(json); + + var job = await client.GetPrintJobAsync(99); + + handler.LastRequest!.Method.ShouldBe(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/printjobs/99"); + var item = job.Items.ShouldHaveSingleItem(); + item.TrackingCode.ShouldBe("RC1DE"); + item.Base64Data.ShouldBe("QUJD"); + } + + [Fact] + public async Task GetEmailJobAsync_MapsNestedPrintJob() + { + const string json = """ + {"status":200,"message":"OK","data":{"id":918,"email_receiver":"j@aof.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"success","printjob":{"id":138037,"status":"done","c4":0,"bank_form":0,"items":[{"address":"X","pages":1,"amount":0.67,"vat":0.13,"status":"sent"}]}}} + """; + var (client, _) = CreateClient(json); + + var job = await client.GetEmailJobAsync(918); + + job.Id.ShouldBe(918); + job.PrintJob.ShouldNotBeNull(); + job.PrintJob!.Id.ShouldBe(138037); + } + + [Fact] + public async Task DeleteEmailJobAsync_UsesDeleteMethod() + { + const string json = """{"status":200,"message":"Email job deleted successfully"}"""; + var (client, handler) = CreateClient(json); + + await client.DeleteEmailJobAsync(3902); + + handler.LastRequest!.Method.ShouldBe(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/emailjobs/3902"); + } + + [Fact] + public async Task UpdateEmailJobAsync_SendsNewReceiver() + { + const string json = """{"status":200,"message":"OK","data":{"id":3902,"email_receiver":"new@aof.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"}}"""; + var (client, handler) = CreateClient(json); + + var job = await client.UpdateEmailJobAsync(3902, "new@aof.de"); + + handler.LastRequest!.Method.ShouldBe(HttpMethod.Put); + BodyRoot(handler).GetProperty("email").GetProperty("email_receiver").GetString().ShouldBe("new@aof.de"); + job.EmailReceiver.ShouldBe("new@aof.de"); + } + + [Fact] + public async Task ListEmailJobsAsync_WithFilter_ParsesAndBuildsQuery() + { + const string json = """ + {"status":200,"message":"OK","data":{"emailjobs":[{"id":3093,"email_receiver":"j@aof.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"hold"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15,"first_page_url":"https://api.letterxpress.de/v3/emailjobs?page=1","last_page_url":"https://api.letterxpress.de/v3/emailjobs?page=1"}}} + """; + var (client, handler) = CreateClient(json); + + var result = await client.ListEmailJobsAsync(EmailJobFilter.Hold); + + handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=hold"); + result.Items.ShouldHaveSingleItem().Id.ShouldBe(3093); + result.Pagination!.FirstPageUrl.ShouldNotBeNull(); + result.Pagination.LastPageUrl.ShouldNotBeNull(); + } + + [Fact] + public async Task ListInvoicesAsync_MapsItems() + { + const string json = """ + {"status":200,"message":"OK","data":{"invoices":[{"id":25402,"amount":0.67,"vat":0.13,"invoice_date":"2020-05-31"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15}}} + """; + var (client, _) = CreateClient(json); + + var result = await client.ListInvoicesAsync(); + + var inv = result.Items.ShouldHaveSingleItem(); + inv.Id.ShouldBe(25402); + inv.InvoiceDate.ShouldBe(new DateOnly(2020, 5, 31)); + } + + [Fact] + public async Task ListPrintJobsAsync_NoFilterOrPage_HasNoQuery() + { + const string json = """{"status":200,"message":"OK","data":{"printjobs":[],"pagination":{"total":0,"count":0,"current_page":1,"last_page":1,"per_page":50}}}"""; + var (client, handler) = CreateClient(json); + + await client.ListPrintJobsAsync(); + + handler.LastRequest!.RequestUri!.Query.ShouldBeEmpty(); + } + + // ---- Timezone (DST) ------------------------------------------------- + + [Fact] + public async Task ParseDateTime_WinterDate_UsesCetPlusOne() + { + var berlin = TryGetBerlin(); + if (berlin is null) + { + Assert.Skip("Europe/Berlin timezone data not available on this machine."); + } + + const string json = """ + {"status":200,"message":"OK","data":{"transactions":[{"amount":-1.0,"currency":"EUR","description":"x","created_at":"2024-01-15 09:00:00"}],"pagination":{"total":1,"count":1,"current_page":1,"last_page":1,"per_page":15}}} + """; + var (client, _) = CreateClient(json); + + var result = await client.ListTransactionsAsync(); + + // January => CET (UTC+1). + result.Items.ShouldHaveSingleItem().CreatedAt + .ShouldBe(new DateTimeOffset(2024, 1, 15, 9, 0, 0, TimeSpan.FromHours(1))); + } + + private static TimeZoneInfo? TryGetBerlin() + { + foreach (var id in (string[])["Europe/Berlin", "W. Europe Standard Time"]) + { + try { return TimeZoneInfo.FindSystemTimeZoneById(id); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + } + return null; + } +} diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs new file mode 100644 index 0000000..56e44ea --- /dev/null +++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/LetterXpressClientTests.cs @@ -0,0 +1,305 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Parcel.NET.LetterXpress.Letters.Models; +using Shouldly; +using Xunit; + +namespace Parcel.NET.LetterXpress.Letters.Tests; + +public class LetterXpressClientTests +{ + private static readonly byte[] SamplePdf = Encoding.UTF8.GetBytes("%PDF-1.4 sample"); + + private static (LetterXpressClient Client, MockHttpMessageHandler Handler) CreateClient( + string responseBody, HttpStatusCode status = HttpStatusCode.OK, bool testMode = false) + { + var handler = new MockHttpMessageHandler(responseBody, status); + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.letterxpress.de/v3/") }; + var options = Options.Create(new LetterXpressOptions + { + Username = "testuser", + ApiKey = "secret-key", + UseTestMode = testMode + }); + return (new LetterXpressClient(httpClient, options), handler); + } + + private static JsonElement BodyRoot(MockHttpMessageHandler handler) => + JsonDocument.Parse(handler.LastRequestBody!).RootElement; + + [Fact] + public async Task GetBalanceAsync_ReturnsBalance() + { + const string json = """ + {"status":200,"message":"OK","data":{"balance":54.89,"currency":"EUR"}} + """; + var (client, _) = CreateClient(json); + + var balance = await client.GetBalanceAsync(); + + balance.Amount.ShouldBe(54.89m); + balance.Currency.ShouldBe("EUR"); + } + + [Fact] + public async Task GetBalanceAsync_SendsAuthInBody() + { + const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}"""; + var (client, handler) = CreateClient(json, testMode: true); + + await client.GetBalanceAsync(); + + var auth = BodyRoot(handler).GetProperty("auth"); + auth.GetProperty("username").GetString().ShouldBe("testuser"); + auth.GetProperty("apikey").GetString().ShouldBe("secret-key"); + auth.GetProperty("mode").GetString().ShouldBe("test"); + } + + [Fact] + public async Task GetBalanceAsync_UsesGetMethod() + { + const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}"""; + var (client, handler) = CreateClient(json); + + await client.GetBalanceAsync(); + + handler.LastRequest!.Method.ShouldBe(HttpMethod.Get); + handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/balance"); + } + + [Fact] + public async Task CreatePrintJobAsync_EncodesFileAndChecksum() + { + const string json = """ + {"status":200,"message":"OK","data":{"id":6035143,"shipping":"national","mode":"simplex","color":"4","c4":0,"bank_form":0,"status":"queue","created_at":"2022-08-01 13:38:07","updated_at":"2022-08-01 13:38:07","items":[{"address":"Jim Knopf","pages":1,"amount":0.68,"vat":0.13,"status":"queue"}]}} + """; + var (client, handler) = CreateClient(json); + + var job = await client.CreatePrintJobAsync(new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification + { + Color = LetterColor.Color, + Mode = PrintMode.Simplex, + Shipping = ShippingType.National + } + }); + + var letter = BodyRoot(handler).GetProperty("letter"); + var expectedBase64 = Convert.ToBase64String(SamplePdf); + var expectedChecksum = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(expectedBase64))).ToLowerInvariant(); + + letter.GetProperty("base64_file").GetString().ShouldBe(expectedBase64); + letter.GetProperty("base64_file_checksum").GetString().ShouldBe(expectedChecksum); + letter.GetProperty("specification").GetProperty("color").GetString().ShouldBe("4"); + letter.GetProperty("specification").GetProperty("shipping").GetString().ShouldBe("national"); + + job.Id.ShouldBe(6035143); + job.Status.ShouldBe("queue"); + job.Items.ShouldHaveSingleItem().Amount.ShouldBe(0.68m); + } + + [Fact] + public async Task CreatePrintJobAsync_MapsRegisteredAndDispatchDate() + { + const string json = """{"status":200,"message":"OK","data":{"id":1,"status":"queue"}}"""; + var (client, handler) = CreateClient(json); + + await client.CreatePrintJobAsync(new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification { Shipping = ShippingType.National }, + Registered = RegisteredMail.EinschreibenEinwurf, + DispatchDate = new DateOnly(2026, 9, 1), + Notice = "KdNr. 4711" + }); + + var letter = BodyRoot(handler).GetProperty("letter"); + letter.GetProperty("registered").GetString().ShouldBe("r1"); + letter.GetProperty("dispatch_date").GetString().ShouldBe("2026-09-01"); + letter.GetProperty("notice").GetString().ShouldBe("KdNr. 4711"); + } + + [Fact] + public async Task GetPriceAsync_SendsPagesAndReturnsPrice() + { + const string json = """ + {"status":200,"message":"OK","data":{"price":4.99,"letter":{"specification":{"pages":1,"color":"4","mode":"simplex","shipping":"national"}}}} + """; + var (client, handler) = CreateClient(json); + + var price = await client.GetPriceAsync(new PriceRequest + { + Pages = 1, + Color = LetterColor.Color, + Shipping = ShippingType.National, + EmailOption = EmailOption.MailSecure + }); + + var spec = BodyRoot(handler).GetProperty("letter").GetProperty("specification"); + spec.GetProperty("pages").GetInt32().ShouldBe(1); + spec.GetProperty("email_option").GetString().ShouldBe("mailsecure"); + + price.Price.ShouldBe(4.99m); + price.Pages.ShouldBe(1); + price.Shipping.ShouldBe("national"); + } + + [Fact] + public async Task ListPrintJobsAsync_BuildsFilterAndPageQuery() + { + const string json = """ + {"status":200,"message":"OK","data":{"printjobs":[{"id":1,"status":"hold","c4":0,"bank_form":0,"items":[]}],"pagination":{"total":7,"count":7,"current_page":1,"last_page":1,"per_page":15}}} + """; + var (client, handler) = CreateClient(json); + + var result = await client.ListPrintJobsAsync(PrintJobFilter.Hold, page: 2); + + handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=hold"); + handler.LastRequest.RequestUri.Query.ShouldContain("page=2"); + result.Items.ShouldHaveSingleItem().Id.ShouldBe(1); + result.Pagination!.Total.ShouldBe(7); + } + + [Fact] + public async Task DeletePrintJobAsync_UsesDeleteMethod() + { + const string json = """{"status":200,"message":"Print job deleted successfully"}"""; + var (client, handler) = CreateClient(json); + + await client.DeletePrintJobAsync(6035143); + + handler.LastRequest!.Method.ShouldBe(HttpMethod.Delete); + handler.LastRequest.RequestUri!.AbsolutePath.ShouldEndWith("/v3/printjobs/6035143"); + } + + [Fact] + public async Task CreateEmailJobAsync_SingleObject_ReturnsOneEmailJob() + { + const string json = """ + {"status":200,"message":"OK","data":{"id":3096,"email_receiver":"max@letterxpress.email","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"queue","subject":"Ihre Rechnung"}} + """; + var (client, _) = CreateClient(json); + + var result = await client.CreateEmailJobAsync(new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification(), + EmailLetter = new EmailLetterOptions { EmailOption = EmailOption.MailPlus, EmailReceiver = "max@letterxpress.email" } + }); + + var job = result.EmailJobs.ShouldHaveSingleItem(); + job.Id.ShouldBe(3096); + job.EmailOption.ShouldBe("mailplus"); + result.PrintJobs.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateEmailJobAsync_Array_ReturnsMultipleEmailJobs() + { + const string json = """ + {"status":200,"message":"OK","data":[{"id":3813,"email_receiver":"a@x.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"},{"id":3814,"email_receiver":"b@x.de","email_option":"maildirect","amount":0.05,"vat":0.01,"status":"queue"}]} + """; + var (client, _) = CreateClient(json); + + var result = await client.CreateEmailJobAsync(new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification() + }); + + result.EmailJobs.Count.ShouldBe(2); + result.EmailJobs[1].Id.ShouldBe(3814); + } + + [Fact] + public async Task CreateEmailJobAsync_CombinedPrintAndEmail_ReturnsBoth() + { + const string json = """ + {"status":200,"message":"OK","data":{"printjobs":[{"id":138606,"status":"queue","c4":0,"bank_form":0,"items":[]}],"emailjobs":[{"id":3837,"email_receiver":"a@x.de","email_option":"mailplus","amount":0.1,"vat":0.02,"status":"queue","printjob_id":138606}]}} + """; + var (client, _) = CreateClient(json); + + var result = await client.CreateEmailJobAsync(new LetterRequest + { + File = SamplePdf, + Specification = new LetterSpecification() + }); + + result.PrintJobs.ShouldHaveSingleItem().Id.ShouldBe(138606); + var job = result.EmailJobs.ShouldHaveSingleItem(); + job.PrintJobId.ShouldBe(138606); + } + + [Fact] + public async Task GetInvoiceAsync_ReturnsInvoiceWithPdf() + { + const string json = """ + {"status":200,"message":"OK","data":{"id":25402,"amount":0.67,"vat":0.13,"invoice_date":"2020-05-31","base64_data":"xxxxxxxx"}} + """; + var (client, _) = CreateClient(json); + + var invoice = await client.GetInvoiceAsync(25402); + + invoice.Id.ShouldBe(25402); + invoice.InvoiceDate.ShouldBe(new DateOnly(2020, 5, 31)); + invoice.Base64Data.ShouldBe("xxxxxxxx"); + } + + [Fact] + public async Task ListTransactionsAsync_MapsItems() + { + const string json = """ + {"status":200,"message":"OK","data":{"transactions":[{"amount":-1.04,"currency":"EUR","description":"Farbe","created_at":"2022-09-21 15:32:45"}],"pagination":{"total":62,"count":15,"current_page":1,"last_page":5,"per_page":15,"next_page_url":"http://api.letterxpress.de/v3/transactions?page=2"}}} + """; + var (client, handler) = CreateClient(json); + + var result = await client.ListTransactionsAsync(TransactionFilter.PrintJobs); + + handler.LastRequest!.RequestUri!.Query.ShouldContain("filter=printjobs"); + var item = result.Items.ShouldHaveSingleItem(); + item.Amount.ShouldBe(-1.04m); + // Timestamps are German local time (Europe/Berlin). September => CEST (UTC+2). + item.CreatedAt.ShouldBe(new DateTimeOffset(2022, 9, 21, 15, 32, 45, TimeSpan.FromHours(2))); + result.Pagination!.NextPageUrl.ShouldNotBeNull(); + } + + [Fact] + public async Task SendAsync_SetsContentTypeWithoutCharset() + { + // The live API rejects "application/json; charset=utf-8" with HTTP 400. + const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}"""; + var (client, handler) = CreateClient(json); + + await client.GetBalanceAsync(); + + handler.LastRequest!.Content!.Headers.ContentType!.ToString().ShouldBe("application/json"); + } + + [Fact] + public async Task UnauthorizedResponse_ThrowsLetterXpressException() + { + const string json = """{"message":"Unauthorized."}"""; + var (client, _) = CreateClient(json, HttpStatusCode.Unauthorized); + + var ex = await Should.ThrowAsync(() => client.GetBalanceAsync()); + + ex.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + ex.Message.ShouldContain("Unauthorized"); + } + + [Fact] + public async Task LiveMode_SendsLiveInAuth() + { + const string json = """{"status":200,"message":"OK","data":{"balance":1,"currency":"EUR"}}"""; + var (client, handler) = CreateClient(json, testMode: false); + + await client.GetBalanceAsync(); + + BodyRoot(handler).GetProperty("auth").GetProperty("mode").GetString().ShouldBe("live"); + } +} diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs b/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs new file mode 100644 index 0000000..7ea79cf --- /dev/null +++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/MockHttpMessageHandler.cs @@ -0,0 +1,36 @@ +using System.Net; + +namespace Parcel.NET.LetterXpress.Letters.Tests; + +/// +/// Test handler that captures the outgoing request (method, URI, and body) and returns a queued response. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpStatusCode _statusCode; + private readonly string _responseBody; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + + public MockHttpMessageHandler(string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _responseBody = responseBody; + _statusCode = statusCode; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + if (request.Content is not null) + { + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + return new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseBody), + }; + } +} diff --git a/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj b/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj new file mode 100644 index 0000000..29b2987 --- /dev/null +++ b/tests/Parcel.NET.LetterXpress.Letters.Tests/Parcel.NET.LetterXpress.Letters.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net9.0;net10.0 + false + false + $(NoWarn);xUnit1051 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +