Skip to content
Snippets Groups Projects
Verified Commit e3810005 authored by Jonas Zohren's avatar Jonas Zohren 💬
Browse files

Initial commit | WIP

parents
No related branches found
No related tags found
No related merge requests found
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.npmrc 0 → 100644
engine-strict=true
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
# Geld vom FSR anfordern (WebApp)
## Developing
```bash
pnpm run dev
```
## Building
```bash
pnpm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## TODO
- [ ] Render PDF for requesting money
- [ ] Render payout PDFs ("Auszahlungsanweisungen")
- [ ] Render hledger statements to track payout
# Jonas Test-IBAN
```
DE63 1001 1001 2620 2039 05
```
\ No newline at end of file
{
"name": "tudo-fsinfo-geld-bekommen",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@pdf-lib/fontkit": "^1.1.1",
"@playwright/test": "^1.29.1",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/kit": "^1.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.48.0",
"bankdata-germany": "^1.2201.0",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte3": "^4.0.0",
"ibantools": "^4.2.1",
"pdf-lib": "^1.17.1",
"prettier": "^2.8.1",
"prettier-plugin-svelte": "^2.9.0",
"svelte": "^3.55.0",
"svelte-check": "^2.10.3",
"tslib": "^2.4.1",
"typescript": "^4.9.4",
"vite": "^4.0.3",
"vitest": "^0.25.8"
},
"type": "module"
}
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests'
};
export default config;
This diff is collapsed.
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
declare const __APP_VERSION__: string;
declare const __APP_BUILD_DATE__: string;
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<style>
@media (prefers-color-scheme: dark) {
body {
color: #eee;
background: #131313;
}
body a {
color: #809fff;
}
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
import type { Cents } from './types';
const numberFormater = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
export function centsToStr(cents: Cents): string {
return numberFormater.format(cents / 100);
}
export function strToCents(input: string): Cents | null {
input = input.trim().toLowerCase().replace(',', '.');
const parsedCents = Number.parseFloat(input) * 100;
return Math.floor(parsedCents);
}
interface GeocodeAnswer {
hits: {
name: string;
country: string | undefined;
postcode: string | undefined;
housenumber: string | undefined;
point: {
lat: number;
lng: number;
};
}[];
locale: string;
}
export interface RouteAnswer {
hints: Hints;
info: Info;
paths: Path[];
}
export interface Hints {
'visited_nodes.sum': number;
'visited_nodes.average': number;
}
export interface Info {
copyrights: string[];
took: number;
}
export interface Path {
distance: number;
weight: number;
time: number;
transfers: number;
snapped_waypoints: string;
}
const key = encodeURIComponent('d2f6bb02-824d-4fb5-9f79-504acc79c413');
export async function resolvePlaceToLatLong(place: string) {
place = encodeURIComponent(place);
const rawRes = await fetch(
`https://graphhopper.com/api/1/geocode?key=${key}&provider=default&locale=de&q=${place}`,
{
headers: { Accept: 'application/json' },
method: 'GET'
}
);
const res = (await rawRes.json()) as GeocodeAnswer;
return res;
}
export async function calculateRouteLength(
stops: { lat: number; lng: number }[]
): Promise<number | null> {
const rawRes = await fetch(`https://graphhopper.com/api/1/route?key=${key}`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
points: stops.map(({ lat, lng }) => [lat, lng]),
profile: 'car',
calc_points: false,
elevation: true,
instructions: false,
optimize: false,
debug: false
}),
method: 'POST'
});
const res = (await rawRes.json()) as RouteAnswer;
return res?.paths?.[0]?.distance ?? null;
}
import { PDFDocument, rgb, PageSizes, type PDFPage, type PDFFont } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import type { DataSummary } from './summarize';
import type { SpendingItem } from './types';
// Fetch the Ubuntu font
const ubuntuFontUrl = 'https://pdf-lib.js.org/assets/ubuntu/Ubuntu-R.ttf';
const ubuntuFontBytesProm = fetch(ubuntuFontUrl).then((res) => res.arrayBuffer());
export async function renderSummaryToPdf({
title,
payoutItems,
spendingItems
}: DataSummary): Promise<string> {
const pdfDoc = await PDFDocument.create();
pdfDoc.setTitle('Kostenabrechnung ' + title)
// Embed the Ubuntu font
pdfDoc.registerFontkit(fontkit);
const ubuntuFont = await pdfDoc.embedFont(await ubuntuFontBytesProm);
const form = pdfDoc.getForm();
form.updateFieldAppearances(ubuntuFont);
const titlePage = pdfDoc.addPage(PageSizes.A4);
const pageHeight = titlePage.getHeight();
const pageWidth = titlePage.getWidth();
titlePage.drawText('Kostenabrechnung', { x: 5, y: pageHeight - 20, size: 20, font: ubuntuFont });
titlePage.drawLine({
start: { x: 0, y: pageHeight - 30 },
end: { x: pageWidth, y: pageHeight - 30 },
thickness: 2
});
titlePage.drawText('Betreff', { x: 10, y: pageHeight - 55, size: 12, font: ubuntuFont });
const subjectField = form.createTextField('subject');
subjectField.setText('FooBar-Einkauf 30.02.2099');
subjectField.addToPage(titlePage, {
x: 100,
y: pageHeight - 60,
width: pageWidth - 110,
height: 20
});
titlePage.drawText('Datum', { x: 10, y: pageHeight - 85, size: 12, font: ubuntuFont });
const dateField = form.createTextField('date');
dateField.setText('12.04.2099');
dateField.addToPage(titlePage, {
x: 100,
y: pageHeight - 90,
width: pageWidth - 110,
height: 20
});
titlePage.drawLine({
start: { x: 0, y: pageHeight - 100 },
end: { x: pageWidth, y: pageHeight - 100 },
thickness: 1
});
titlePage.drawText('Posten:', { x: 10, y: pageHeight - 120, size: 16, font: ubuntuFont });
await drawTitleTable(pdfDoc, titlePage, pageWidth, pageHeight, ubuntuFont);
await addReceiptPages(spendingItems, pdfDoc);
const pdfDataUri = await pdfDoc.saveAsBase64({ dataUri: true });
return pdfDataUri;
}
async function drawTitleTable(
pdfDoc: PDFDocument,
page: PDFPage,
pageWidth: number,
pageHeight: number,
font: PDFFont
) {
const tableHeight = 300;
const tableStartY = pageHeight - 140;
const tableEndY = tableStartY - tableHeight;
page.drawRectangle({
x: 10,
y: tableEndY,
width: pageWidth - 20,
height: 300,
borderWidth: 1,
opacity: 0
});
const form = pdfDoc.getForm();
let counter = 1;
for (let y = tableStartY - 20; y > tableEndY; y -= 20) {
page.drawLine({
start: { x: 10, y },
end: { x: pageWidth - 10, y },
thickness: 1
});
const NrField = form.createTextField(`item.${counter}.counter`);
NrField.setText((counter++).toString());
NrField.addToPage(page, {
x: 11,
y: y - 19,
width: 38,
height: 18
});
const subjectField = form.createTextField(`item.${counter}.subject`);
subjectField.setText('');
subjectField.addToPage(page, {
x: 51,
y: y - 19,
width: 348,
height: 18
});
const sourceField = form.createTextField(`item.${counter}.source`);
sourceField.setText('');
sourceField.addToPage(page, {
x: 401,
y: y - 19,
width: 98,
height: 18
});
const amountField = form.createTextField(`item.${counter}.amount`);
amountField.setText('');
amountField.addToPage(page, {
x: 501,
y: y - 19,
width: pageWidth - 10 - 502,
height: 18
});
}
page.drawLine({
start: { x: 400, y: tableStartY },
end: { x: 400, y: tableEndY },
thickness: 1
});
page.drawLine({
start: { x: 500, y: tableStartY },
end: { x: 500, y: tableEndY },
thickness: 1
});
page.drawLine({
start: { x: 50, y: tableStartY },
end: { x: 50, y: tableEndY },
thickness: 1
});
page.drawText('Nr.', { x: 20, y: tableStartY - 14, size: 10 , font: font });
page.drawText('Bezeichnung.', { x: 60, y: tableStartY - 14, size: 10 , font: font });
page.drawText('Beschluss / Topf.', { x: 410, y: tableStartY - 14, size: 10 , font: font });
page.drawText('Betrag in EUR.', { x: 510, y: tableStartY - 14, size: 10, font: font });
}
async function addReceiptPages(spendingItems: SpendingItem[], pdfDoc: PDFDocument) {
for (const spendingItem of spendingItems) {
if (!spendingItem.receipt) {
continue;
}
const receipt = spendingItem.receipt;
console.log(receipt.type);
if (receipt.type.startsWith('image/')) {
const imageBytes = await receipt.arrayBuffer();
const image =
receipt.type === 'image/png'
? await pdfDoc.embedPng(imageBytes)
: await pdfDoc.embedJpg(imageBytes);
const imageDimensions = image.scale(0.5);
const page = pdfDoc.addPage();
page.moveTo(5, page.getHeight() - 30);
page.drawText('Rechnung für ' + spendingItem.description);
page.drawImage(image, {
x: 25,
y: 25,
width: imageDimensions.width,
height: imageDimensions.height
});
}
if (receipt.type == 'application/pdf') {
}
}
}
export async function findResolutions(sourceName: string) {
const rawRes = await fetch('/api/resolution/autocomplete?query=' + sourceName);
const res: {
lastUpdated: string;
boundMoney: {
name: string;
cents: number;
}[];
} = await rawRes.json();
// Manually add Kiosk, as it is not in list bound fsr money, but separate
res.boundMoney.push({
name: 'Kiosk',
cents: NaN
});
return res;
}
const fsrTranscriptsIndexJsonUrl = 'https://tudo-fsinfo.fspages.org/fsr/sitzungen/index.json';
export interface FsrTranscriptsIndex {
categories: null;
contents: string;
date: null | string;
number: number | null;
permalink: string;
resolutions: FsrRawResolution[] | null;
tags: null;
title: string;
}
export interface FsrRawResolution {
date: string;
number: string;
result: string;
text: string;
votes: {
abstention: number;
no: number;
yes: number;
};
money_granted?: number | string;
note?: string;
}
import type { Cents, IBAN, PayeeInfo, SpendingItem } from './types';
export interface DataSummary {
title: string;
spendingItems: SpendingItem[];
payoutItems: {
name: string;
iban: IBAN;
amountInCents: Cents;
}[];
}
export function summarize(
title: string,
spendingItems: SpendingItem[],
payeeInfos: PayeeInfo[]
): DataSummary {
const output: DataSummary = {
title: title,
spendingItems: spendingItems,
payoutItems: []
};
for (const { name, iban } of payeeInfos) {
const thisPersonsSpendingSum: Cents = spendingItems
.filter((si) => si.payee === name)
.map((si) => si.amountInCt)
.reduce((sum, x) => (sum += x), 0); // sum
output.payoutItems.push({
name: name,
iban: iban,
amountInCents: thisPersonsSpendingSum
});
}
return output;
}
export type Cents = number;
export interface SpendingItem {
description: string;
amountInCt: Cents;
payee: string;
source: string;
receipt: File | null;
}
export interface DrivingCostsItem {
// TODO
}
export type IBAN = string;
export interface PayeeInfo {
name: string;
iban: IBAN;
}
export interface HLedgerAccountBalance {
cbrDates: Array<[string, string]>;
cbrSubreports: Array<[string, CbrSubreportClass, boolean]>;
cbrTitle: string;
cbrTotals: RTotals;
}
export interface CbrSubreportClass {
prDates: Array<[string, string]>;
prRows: PRRow[];
prTotals: RTotals;
}
export interface PRRow {
prrAmounts: Array<Prr[]>;
prrAverage: Prr[];
prrName: string;
prrTotal: Prr[];
}
export interface Prr {
acommodity: Acommodity;
aprice: null;
aquantity: Aquantity;
astyle: Astyle;
}
export enum Acommodity {
Empty = ''
}
export interface Aquantity {
decimalMantissa: number;
decimalPlaces: number;
floatingPoint: number;
}
export interface Astyle {
ascommodityside: Ascommodityside;
ascommodityspaced: boolean;
asdecimalpoint: Asdecimalpoint;
asdigitgroups: [string, number[]];
asprecision: number;
}
export enum Ascommodityside {
R = 'R'
}
export enum Asdecimalpoint {
Empty = ','
}
export enum AsdigitgroupEnum {
Empty = '.'
}
export interface RTotals {
prrAmounts: Array<Prr[]>;
prrAverage: Prr[];
prrName: unknown[];
prrTotal: Prr[];
}
<slot />
<footer>
<hr />
<!-- svelte-ignore missing-declaration -->
<p>
<a href="#TODO" target="_blank" rel="noopener">Source Code</a>
|
<a href="https://oh14.de/datenschutzerklaerung.html" target="_blank" rel="noreferrer noopener"
>Datenschutz</a
>
|
<a href="https://oh14.de/impressum.html" target="_blank" rel="noreferrer noopener">Impressum</a>
</p>
<!-- svelte-ignore missing-declaration -->
<p>
Version {__APP_VERSION__} (build {__APP_BUILD_DATE__})
</p>
</footer>
<style>
footer {
width: 100%;
}
p {
text-align: center;
color: gray;
}
</style>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment