mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 19:07:06 +01:00
billsplit was a standalone Flutter app in ~/Downloads with one commit and no git remote — that directory was the only copy of its history. Three things the move needed beyond copying the tree: - The root .gitignore silently swallowed 32 of its files (*.png, *.ico, *.manifest). Ignored files do not error on `git add`, they just vanish, so this would have committed an app that cannot build. Added a trailing block of ! overrides plus .binary-allowlist (which did not exist yet) — the documented in-repo exception for binaries a build genuinely needs. The ../testsAndMisc_binaries/ symlink scheme does not apply: those directories are gone, and a symlinked PNG breaks a fresh clone. - billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads workflows at the repository root. Relocated with a working-directory and a billsplit/** path filter. - The app's coverage gate moved to python_pkg/billsplit_coverage/ so every Python file stays under python_pkg/, and gained a test suite at 100% branch coverage as that location requires. billsplit/ now holds only Dart. Also replaces the stock Flutter icon with a family icon from the shared generator. The glyph is a filled silhouette because the family's 72px stroke cannot carry a fine torn edge, and the split is an evenodd-cut perforation so the holes stay transparent on the foreground and monochrome layers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
294 lines
8.2 KiB
Dart
294 lines
8.2 KiB
Dart
/// Parser for Polish fiscal e-receipts (e-paragon / JPK_KASA_PARAGON_v2-0),
|
|
/// e.g. the JSON files produced by the Biedronka app.
|
|
///
|
|
/// Accepted inputs:
|
|
/// * the full wrapper JSON containing a `data` field with a JWS
|
|
/// (header.payload.signature, base64url) whose payload is the JPK document;
|
|
/// * the decoded JPK payload itself (an object with `dokument.paragon`);
|
|
/// * a bare JWS token string.
|
|
///
|
|
/// The signature is NOT verified — this app only reads the contents.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:billsplit/domain/models.dart';
|
|
|
|
/// A parsed receipt plus non-fatal [warnings] collected on the way.
|
|
class EParagonResult {
|
|
/// Bundles a parsed [receipt] with its [warnings].
|
|
EParagonResult({required this.receipt, required this.warnings});
|
|
|
|
/// The parsed receipt.
|
|
final Receipt receipt;
|
|
|
|
/// Human-readable warnings (e.g. sum mismatches, skipped lines).
|
|
final List<String> warnings;
|
|
}
|
|
|
|
/// Thrown when the input cannot be interpreted as an e-paragon.
|
|
class EParagonParseException implements Exception {
|
|
/// Creates the exception with a user-facing [message].
|
|
EParagonParseException(this.message);
|
|
|
|
/// User-facing description of what went wrong.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'EParagonParseException: $message';
|
|
}
|
|
|
|
const _alcoholKeywords = [
|
|
'wódk',
|
|
'wodka',
|
|
'vodka',
|
|
'piwo',
|
|
'wino',
|
|
'whisk',
|
|
'likier',
|
|
'nalewk',
|
|
'cydr',
|
|
' rum',
|
|
'gin ',
|
|
'tequil',
|
|
'brandy',
|
|
'koniak',
|
|
'bourbon',
|
|
];
|
|
|
|
const _mixerKeywords = [
|
|
'tonic',
|
|
'tonik',
|
|
'kinley',
|
|
'citrus mix',
|
|
'limonka',
|
|
'cytryna',
|
|
];
|
|
|
|
/// Parses [source] and returns just the receipt; see [parseEParagonDetailed].
|
|
Receipt parseEParagon(String source) => parseEParagonDetailed(source).receipt;
|
|
|
|
/// Parses [source] (wrapper JSON, bare JPK payload, or bare JWS) into a
|
|
/// [Receipt] plus warnings. Throws [EParagonParseException] on unusable input.
|
|
EParagonResult parseEParagonDetailed(String source) {
|
|
final warnings = <String>[];
|
|
final payload = _extractPayload(source);
|
|
|
|
final dokument = payload['dokument'];
|
|
if (dokument is! Map) {
|
|
throw EParagonParseException('No "dokument" object in payload.');
|
|
}
|
|
final paragon = dokument['paragon'];
|
|
if (paragon is! Map) {
|
|
throw EParagonParseException('No "dokument.paragon" object in payload.');
|
|
}
|
|
|
|
final store = _storeName(dokument);
|
|
final date = _saleDate(paragon, dokument);
|
|
|
|
final items = <Item>[];
|
|
final pozycje = paragon['pozycja'];
|
|
if (pozycje is List) {
|
|
for (final p in pozycje) {
|
|
final towar = (p is Map) ? p['towar'] : null;
|
|
if (towar is! Map) continue;
|
|
final it = _itemFromTowar(towar.cast<String, dynamic>(), warnings);
|
|
if (it != null) items.add(it);
|
|
}
|
|
}
|
|
if (items.isEmpty) {
|
|
throw EParagonParseException('Receipt has no line items.');
|
|
}
|
|
|
|
// Bottle deposits ("opakowania zwrotne" / kaucja) are outside sumaBrutto but
|
|
// part of what was paid; add each as a regular splittable line.
|
|
final opak = paragon['opak'];
|
|
if (opak is Map) {
|
|
final dane = opak['daneOpak'];
|
|
if (dane is List) {
|
|
for (final d in dane) {
|
|
if (d is! Map) continue;
|
|
final cena = _asInt(d['cena']);
|
|
final iloscMil = _asInt(d['ilosc']); // thousandths
|
|
final qty = iloscMil / 1000;
|
|
final total = (cena * iloscMil / 1000).round();
|
|
if (total == 0) continue;
|
|
items.add(
|
|
Item(
|
|
id: Ids.next(),
|
|
name: 'Kaucja: '
|
|
'${(d['nazwa'] as String? ?? 'opakowanie').trim()} (deposit)',
|
|
totalGr: total,
|
|
qty: qty,
|
|
unitPriceGr: cena,
|
|
category: Categories.deposit,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reconciliation figures.
|
|
var paid = _asIntOrNull((paragon['total'] as Map?)?['zaplZwrot']);
|
|
final sumaBrutto = _asIntOrNull((paragon['podsum'] as Map?)?['sumaBrutto']);
|
|
final opakWart = _asIntOrNull((paragon['opak'] as Map?)?['wart']) ?? 0;
|
|
paid ??= sumaBrutto == null ? null : sumaBrutto + opakWart;
|
|
|
|
if (sumaBrutto != null) {
|
|
final goods = items
|
|
.where((i) => i.category != Categories.deposit)
|
|
.fold(0, (s, i) => s + i.totalGr);
|
|
if (goods != sumaBrutto) {
|
|
warnings.add(
|
|
'Sum of parsed items (${formatPln(goods)}) differs from receipt '
|
|
'sumaBrutto (${formatPln(sumaBrutto)}).',
|
|
);
|
|
}
|
|
}
|
|
|
|
final title = [
|
|
if (store != null) store,
|
|
_iso(date),
|
|
].join(' ');
|
|
|
|
return EParagonResult(
|
|
receipt: Receipt(
|
|
id: Ids.next(),
|
|
title: title,
|
|
store: store,
|
|
date: date,
|
|
items: items,
|
|
paidTotalGr: paid,
|
|
),
|
|
warnings: warnings,
|
|
);
|
|
}
|
|
|
|
String? _storeName(Map<dynamic, dynamic> dokument) {
|
|
final podmiot = dokument['podmiot1'];
|
|
if (podmiot is! Map) return null;
|
|
final name = (podmiot['nazwaPod'] as String?)?.trim();
|
|
if (name == null || name.isEmpty) return null;
|
|
// Friendlier alias for the most common chain.
|
|
if (name.toUpperCase().contains('JERONIMO MARTINS')) return 'Biedronka';
|
|
return name;
|
|
}
|
|
|
|
DateTime _saleDate(
|
|
Map<dynamic, dynamic> paragon,
|
|
Map<dynamic, dynamic> dokument,
|
|
) {
|
|
final raw =
|
|
paragon['zakSprzed'] ?? (dokument['naglowek'] as Map?)?['dataJPK'];
|
|
if (raw is String) {
|
|
final d = DateTime.tryParse(raw);
|
|
if (d != null) return d.toLocal();
|
|
}
|
|
return DateTime.now();
|
|
}
|
|
|
|
String _iso(DateTime d) {
|
|
final m = d.month.toString().padLeft(2, '0');
|
|
final day = d.day.toString().padLeft(2, '0');
|
|
return '${d.year}-$m-$day';
|
|
}
|
|
|
|
Map<String, dynamic> _extractPayload(String source) {
|
|
dynamic root;
|
|
try {
|
|
root = jsonDecode(source.trim());
|
|
} on FormatException {
|
|
// Maybe the user pasted just the JWS token.
|
|
final p = _tryJws(source.trim());
|
|
if (p != null) return p;
|
|
throw EParagonParseException(
|
|
'Input is neither valid JSON nor a JWS.',
|
|
);
|
|
}
|
|
if (root is Map) {
|
|
if (root['dokument'] is Map) return root.cast<String, dynamic>();
|
|
final data = root['data'];
|
|
if (data is String) {
|
|
final p = _tryJws(data);
|
|
if (p != null) return p;
|
|
throw EParagonParseException('"data" field is not a decodable JWS.');
|
|
}
|
|
}
|
|
throw EParagonParseException(
|
|
'Unrecognized structure: expected e-paragon wrapper or JPK payload.',
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic>? _tryJws(String token) {
|
|
final parts = token.split('.');
|
|
if (parts.length != 3) return null;
|
|
try {
|
|
final normalized = base64Url.normalize(parts[1]);
|
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
|
final obj = jsonDecode(decoded);
|
|
return obj is Map ? obj.cast<String, dynamic>() : null;
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Item? _itemFromTowar(Map<String, dynamic> t, List<String> warnings) {
|
|
var name = (t['nazwa'] as String? ?? '').trim();
|
|
// Names end with the VAT rate letter, e.g. "Tonic A".
|
|
if (name.length > 2 &&
|
|
name[name.length - 2] == ' ' &&
|
|
RegExp(r'[A-G]$').hasMatch(name)) {
|
|
name = name.substring(0, name.length - 2).trim();
|
|
}
|
|
name = name.replaceAll(RegExp(r'\s+'), ' ');
|
|
if (name.isEmpty) return null;
|
|
|
|
final total = _asIntOrNull(t['brutto']);
|
|
if (total == null) {
|
|
warnings.add('Skipped "$name": missing brutto amount.');
|
|
return null;
|
|
}
|
|
final unit = _asIntOrNull(t['cena']) ?? 0;
|
|
final qty = _parseQty(t['ilosc']);
|
|
final rabat = t['rabat'];
|
|
final discount = rabat is Map ? (_asIntOrNull(rabat['wart']) ?? 0) : 0;
|
|
|
|
final lower = name.toLowerCase();
|
|
var category = Categories.general;
|
|
var assignment = Assignment();
|
|
if (_alcoholKeywords.any(lower.contains)) {
|
|
category = Categories.alcohol;
|
|
assignment = Assignment(mode: AssignMode.drinkers);
|
|
} else if (_mixerKeywords.any(lower.contains)) {
|
|
category = Categories.mixer; // still split among everyone by default
|
|
}
|
|
|
|
return Item(
|
|
id: Ids.next(),
|
|
name: name,
|
|
totalGr: total,
|
|
qty: qty,
|
|
unitPriceGr: unit,
|
|
discountGr: discount,
|
|
category: category,
|
|
assignment: assignment,
|
|
);
|
|
}
|
|
|
|
double _parseQty(dynamic v) {
|
|
if (v is num) return v.toDouble();
|
|
if (v is String) {
|
|
return double.tryParse(v.replaceAll(',', '.').trim()) ?? 1;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
int _asInt(dynamic v) => _asIntOrNull(v) ?? 0;
|
|
|
|
int? _asIntOrNull(dynamic v) {
|
|
if (v is int) return v;
|
|
if (v is num) return v.round();
|
|
if (v is String) return int.tryParse(v);
|
|
return null;
|
|
}
|