Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class CommandLineException : Exception
{
public CommandLineException(string message)
: base(message)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class CommandLineOptions
{
public string InvoiceJsonPath { get; init; } = Path.Combine("data", "metadata.json");

public string LineItemsCsvPath { get; init; } = Path.Combine("data", "line-items.csv");

public string StyleJsonPath { get; init; } = Path.Combine("data", "style.json");

public string OutputPdfPath { get; init; } = "CreateInvoiceFromStructuredData-out.pdf";

public static bool IsHelpArgument(string arg)
{
return string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) ||
string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) ||
string.Equals(arg, "/?", StringComparison.OrdinalIgnoreCase);
}

public static CommandLineOptions Parse(string[] args)
{
string invoiceJsonPath = Path.Combine("data", "metadata.json");
string lineItemsCsvPath = Path.Combine("data", "line-items.csv");
string styleJsonPath = Path.Combine("data", "style.json");
string outputPdfPath = "CreateInvoiceFromStructuredData-out.pdf";

for (int i = 0; i < args.Length; i++)
{
string arg = args[i];

switch (arg.ToLowerInvariant())
{
case "--invoice":
case "--metadata":
invoiceJsonPath = RequireValue(args, ref i, arg);
break;

case "--line-items":
lineItemsCsvPath = RequireValue(args, ref i, arg);
break;

case "--style":
styleJsonPath = RequireValue(args, ref i, arg);
break;

case "--output":
outputPdfPath = RequireValue(args, ref i, arg);
break;

default:
throw new CommandLineException($"Unknown option: {arg}");
}
}

return new CommandLineOptions
{
InvoiceJsonPath = invoiceJsonPath,
LineItemsCsvPath = lineItemsCsvPath,
StyleJsonPath = styleJsonPath,
OutputPdfPath = outputPdfPath
};
}

public static void PrintUsage()
{
Console.WriteLine("Usage:");
Console.WriteLine(" CreateInvoiceFromStructuredData.exe [options]");
Console.WriteLine();
Console.WriteLine("With no options, the sample reads:");
Console.WriteLine(" data/metadata.json");
Console.WriteLine(" data/line-items.csv");
Console.WriteLine(" data/style.json");
Console.WriteLine("and writes:");
Console.WriteLine(" CreateInvoiceFromStructuredData-out.pdf");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" --metadata <path> JSON file with seller, customer, and invoice metadata.");
Console.WriteLine(" --invoice <path> Alias for --metadata.");
Console.WriteLine(" --line-items <path> CSV file containing invoice line items.");
Console.WriteLine(" --style <path> JSON style configuration for fonts, colors, and sizing.");
Console.WriteLine(" --output <path> Output PDF path.");
Console.WriteLine(" --self-test Validate parsing and totals without creating a PDF.");
Console.WriteLine(" --help, -h, /? Show this help.");
}

private static string RequireValue(string[] args, ref int index, string optionName)
{
if (index + 1 >= args.Length)
{
throw new CommandLineException($"{optionName} requires a value.");
}

index++;
return args[index];
}
}
32 changes: 32 additions & 0 deletions DocumentConversion/CreateInvoiceFromStructuredData/CompanyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class CompanyInfo
{
public string Name { get; init; } = string.Empty;

public string TaxId { get; init; } = string.Empty;

public string AddressLine1 { get; init; } = string.Empty;

public string AddressLine2 { get; init; } = string.Empty;

public string City { get; init; } = string.Empty;

public string Region { get; init; } = string.Empty;

public string PostalCode { get; init; } = string.Empty;

public string Country { get; init; } = string.Empty;

public string Email { get; init; } = string.Empty;

public string Phone { get; init; } = string.Empty;

public IEnumerable<string> AddressLines()
{
yield return AddressLine1;
yield return AddressLine2;
yield return $"{City}, {Region} {PostalCode}".Trim(' ', ',');
yield return Country;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Platforms>x64</Platforms>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Adobe.PDF.Library.LM.NET" Version="21.*" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Globalization;
using System.Text;

namespace CreateInvoiceFromStructuredData;

internal static class CsvLineItemReader
{
public static IReadOnlyList<InvoiceLineItem> Load(string path)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("Line item CSV file was not found.", path);
}

List<string[]> rows = File
.ReadAllLines(path)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(ParseRow)
.ToList();

if (rows.Count < 2)
{
throw new InvalidDataException("Line item CSV must include a header row and at least one line item.");
}

string[] header = rows[0];
Dictionary<string, int> columns = header
.Select((name, index) => new { Name = name.Trim(), Index = index })
.ToDictionary(item => item.Name, item => item.Index, StringComparer.OrdinalIgnoreCase);

return rows
.Skip(1)
.Select((row, index) => CreateLineItem(row, columns, index + 2))
.ToList();
}

public static string[] ParseRow(string row)
{
List<string> values = new();
StringBuilder current = new();
bool inQuotes = false;

for (int i = 0; i < row.Length; i++)
{
char c = row[i];

if (c == '"')
{
if (inQuotes && i + 1 < row.Length && row[i + 1] == '"')
{
current.Append('"');
i++;
}
else
{
inQuotes = !inQuotes;
}

continue;
}

if (c == ',' && !inQuotes)
{
values.Add(current.ToString());
current.Clear();
continue;
}

current.Append(c);
}

values.Add(current.ToString());
return values.ToArray();
}

private static InvoiceLineItem CreateLineItem(string[] row, IReadOnlyDictionary<string, int> columns, int rowNumber)
{
return new InvoiceLineItem
{
ItemCode = Get(row, columns, "itemCode", rowNumber),
Description = Get(row, columns, "description", rowNumber),
Quantity = ParseDecimal(Get(row, columns, "quantity", rowNumber), "quantity", rowNumber),
UnitPrice = ParseDecimal(Get(row, columns, "unitPrice", rowNumber), "unitPrice", rowNumber)
};
}

private static string Get(string[] row, IReadOnlyDictionary<string, int> columns, string columnName, int rowNumber)
{
if (!columns.TryGetValue(columnName, out int index))
{
throw new InvalidDataException($"Line item CSV is missing required column: {columnName}.");
}

if (index >= row.Length)
{
throw new InvalidDataException($"Line item CSV row {rowNumber} is missing a value for {columnName}.");
}

return row[index].Trim();
}

private static decimal ParseDecimal(string value, string columnName, int rowNumber)
{
if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal result))
{
throw new InvalidDataException($"Line item CSV row {rowNumber} has an invalid {columnName} value: {value}");
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json;

namespace CreateInvoiceFromStructuredData;

internal static class InvoiceDataLoader
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};

public static InvoiceInput Load(string path)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("Metadata JSON file was not found.", path);
}

string json = File.ReadAllText(path);
InvoiceInput? invoice = JsonSerializer.Deserialize<InvoiceInput>(json, Options);

if (invoice is null)
{
throw new InvalidDataException("Metadata JSON did not contain a valid invoice object.");
}

Require(invoice.InvoiceNumber, "invoiceNumber");
Require(invoice.Seller.Name, "seller.name");
Require(invoice.Customer.Name, "customer.name");

if (invoice.ApplyRestrictionPassword)
{
Require(invoice.RestrictionPassword, "restrictionPassword");
}

return invoice;
}

private static void Require(string value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidDataException($"Metadata JSON requires a non-empty {fieldName} value.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class InvoiceDocument
{
public InvoiceDocument(InvoiceInput invoice, IReadOnlyList<InvoiceLineItem> lineItems)
{
Invoice = invoice;
LineItems = lineItems;
}

public InvoiceInput Invoice { get; }

public IReadOnlyList<InvoiceLineItem> LineItems { get; }

public decimal Subtotal => LineItems.Sum(item => item.Amount);

public decimal Tax => Math.Round(Subtotal * Invoice.TaxRate, 2, MidpointRounding.AwayFromZero);

public decimal Total => Subtotal + Tax;
}
28 changes: 28 additions & 0 deletions DocumentConversion/CreateInvoiceFromStructuredData/InvoiceInput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class InvoiceInput
{
public string InvoiceNumber { get; init; } = string.Empty;

public string IssueDate { get; init; } = string.Empty;

public string DueDate { get; init; } = string.Empty;

public string Currency { get; init; } = "USD";

public decimal TaxRate { get; init; }

public string LogoPath { get; init; } = string.Empty;

public string PaymentTerms { get; init; } = string.Empty;

public string Notes { get; init; } = string.Empty;

public bool ApplyRestrictionPassword { get; init; } = true;

public string RestrictionPassword { get; init; } = "NSS-Restrict-2026-ReviewOnly!";

public CompanyInfo Seller { get; init; } = new();

public CompanyInfo Customer { get; init; } = new();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace CreateInvoiceFromStructuredData;

internal sealed class InvoiceLineItem
{
public string ItemCode { get; init; } = string.Empty;

public string Description { get; init; } = string.Empty;

public decimal Quantity { get; init; }

public decimal UnitPrice { get; init; }

public decimal Amount => Quantity * UnitPrice;
}
Loading