diff --git a/.bruno/BRF/Invoice.bru b/.bruno/BRF/Invoice.bru deleted file mode 100644 index 773da17..0000000 --- a/.bruno/BRF/Invoice.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: Invoice - type: http - seq: 7 -} - -get { - url: {{base_url}}/ - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/.bruno/BRF/api-invoice--id.bru b/.bruno/BRF/api-invoice--id.bru new file mode 100644 index 0000000..fbe4975 --- /dev/null +++ b/.bruno/BRF/api-invoice--id.bru @@ -0,0 +1,20 @@ +meta { + name: /api/invoice/:id + type: http + seq: 7 +} + +get { + url: {{base_url}}/api/invoices/:id + body: none + auth: inherit +} + +params:path { + id: 234 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bin/add_fisken_invoices.ts b/bin/add_fisken_invoices.ts index 104aa95..97d3fae 100644 --- a/bin/add_fisken_invoices.ts +++ b/bin/add_fisken_invoices.ts @@ -52,8 +52,10 @@ async function readdir(dir: string) { const pathname = path.join('uploads', 'invoices', filename) if (!existsSync(pathname)) { - console.info(filename) + console.info('COPYING: ' + filename) await fs.copyFile(path.join(dir, originalFilename), pathname) + } else { + console.info('ALREADY EXISTS: ' + filename) } const file = (await trx('file').insert({ filename }).returning('id'))[0] diff --git a/bin/add_phm_invoices.ts b/bin/add_phm_invoices.ts index 8bd4967..67f0691 100644 --- a/bin/add_phm_invoices.ts +++ b/bin/add_phm_invoices.ts @@ -58,13 +58,14 @@ for (const row of rows.toReversed()) { const pathname = path.join('uploads', 'invoices', filename) if (!existsSync(pathname)) { - console.info(filename) + console.info('COPYING: ' + filename) await fs.copyFile(path.join('invoices', 'phm', originalFilename), pathname) + } else { + console.info('ALREADY EXISTS: ' + filename) } const file = (await trx('file').insert({ filename }).returning('id'))[0] - - trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) + await trx('filesToInvoice').insert({ fileId: file.id, invoiceId: invoice.id }) } } diff --git a/client/public/components/invoice_page.tsx b/client/public/components/invoice_page.tsx new file mode 100644 index 0000000..04eb88c --- /dev/null +++ b/client/public/components/invoice_page.tsx @@ -0,0 +1,102 @@ +import { h } from 'preact' +import { useEffect, useState } from 'preact/hooks' +import { useRoute } from 'preact-iso' +import rek from 'rek' +import Head from './head.ts' +import { formatNumber } from '../utils/format_number.ts' + +const InvoicePage = () => { + const [invoice, setInvoice] = useState(null) + const route = useRoute() + + useEffect(() => { + rek(`/api/invoices/${route.params.id}`).then((invoice) => setInvoice(invoice)) + }, []) + + return ( +
+ + : Invoice : {invoice?.id} + + +

Invoice

+ + + + + + + + {invoice && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} +
whatwho
ID{invoice.id}
Date{invoice.invoiceDate}
Due Date{invoice.dueDate}
Fisken{invoice.fiskenNumber}
PHM{invoice.phmNumber}
Amount{invoice.amount}
Files + {invoice.files?.map((file) => ( + + {file.filename} + + ))} +
ID{invoice.id}
ID{invoice.id}
+ + + + + + + + + + + + {invoice?.transactions?.map((transaction) => ( + + + + + + + ))} + +
AccountDebitCreditDescription
{transaction.account_number}{transaction.amount >= 0 ? formatNumber(transaction.amount) : null}{transaction.amount < 0 ? formatNumber(Math.abs(transaction.amount)) : null}{transaction.description}
+
+ ) +} + +export default InvoicePage diff --git a/client/public/components/invoices_by_supplier_page.tsx b/client/public/components/invoices_by_supplier_page.tsx index 8ff9a8e..a3e413a 100644 --- a/client/public/components/invoices_by_supplier_page.tsx +++ b/client/public/components/invoices_by_supplier_page.tsx @@ -42,7 +42,9 @@ const InvoicesPage = () => { {invoices.map((invoice) => ( - {invoice.id} + + {invoice.id} + {invoice.fiskenNumber} {invoice.phmNumber} {format(invoice.invoiceDate)} @@ -51,7 +53,9 @@ const InvoicesPage = () => { {invoice.amount} {invoice.files?.map((file) => ( - {file.filename} + + {file.filename} + ))} diff --git a/client/public/routes.ts b/client/public/routes.ts index 983ce40..66a5510 100644 --- a/client/public/routes.ts +++ b/client/public/routes.ts @@ -1,4 +1,5 @@ import Accounts from './components/accounts_page.tsx' +import Invoice from './components/invoice_page.tsx' import Invoices from './components/invoices_page.tsx' import InvoicesBySupplier from './components/invoices_by_supplier_page.tsx' import Objects from './components/objects_page.tsx' @@ -30,12 +31,19 @@ export default [ title: 'Invoices', component: Invoices, }, + { + path: '/invoices/:id', + name: 'invoice', + title: 'Invoice', + component: Invoice, + nav: false, + }, { path: '/invoices/by-supplier/:supplier', name: 'invoices', title: 'Invoices', - nav: false, component: InvoicesBySupplier, + nav: false, }, { path: '/results', diff --git a/client/public/utils/format_number.ts b/client/public/utils/format_number.ts new file mode 100644 index 0000000..f95cdb4 --- /dev/null +++ b/client/public/utils/format_number.ts @@ -0,0 +1,16 @@ +const priceFormatter = new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: 'SEK', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}) + +export const formatPrice = (price) => priceFormatter.format(price) + +const numberFormatter = new Intl.NumberFormat('sv-SE', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}) + +export const formatNumber = (nbr) => numberFormatter.format(nbr) diff --git a/docker/postgres/01-schema.sql b/docker/postgres/01-schema.sql index 65833dc..4b49f72 100644 --- a/docker/postgres/01-schema.sql +++ b/docker/postgres/01-schema.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict x1LNwo1MrXgJU7KVXELaKlpEPpIZLjRGuaweMIify4ofZcwqTGzXVX5DkRI11Hx +\restrict LKaLyA1IGSZwb31KR2e5GrFZPB7KuPkTsiIxHVwV0aqqukXTaBdKHj8rPqgoMsg -- Dumped from database version 18.1 -- Dumped by pg_dump version 18.1 @@ -239,7 +239,7 @@ CREATE TABLE public.invoice ( fisken_number integer, phm_number integer, invoice_number text, - invoice_date date CONSTRAINT invoice_date_not_null NOT NULL, + invoice_date date, due_date date, ocr text, amount numeric(12,2) @@ -751,5 +751,5 @@ ALTER TABLE ONLY public.transactions_to_objects -- PostgreSQL database dump complete -- -\unrestrict x1LNwo1MrXgJU7KVXELaKlpEPpIZLjRGuaweMIify4ofZcwqTGzXVX5DkRI11Hx +\unrestrict LKaLyA1IGSZwb31KR2e5GrFZPB7KuPkTsiIxHVwV0aqqukXTaBdKHj8rPqgoMsg diff --git a/server/lib/parse_stream.ts b/server/lib/parse_stream.ts index c14b63c..df41ac7 100644 --- a/server/lib/parse_stream.ts +++ b/server/lib/parse_stream.ts @@ -41,8 +41,9 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod const journals = new Map() let currentEntryId: number - const details: Record = {} + let currentInvoiceId: number let currentYear = null + const details: Record = {} const trx = await knex.transaction() @@ -191,12 +192,43 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod } case '#TRANS': { const { objectList, ...transaction } = parseTrans(line) + // Faktura 6364710056 172-2 - SBAB Bank AB + // Faktura 2312 172-6 - Bredablick Frvaltning AB + const rFisken = /Faktura.*172-(\d*)\s+-\s+(.+)/ + + const result = transaction.description?.match(rFisken) + + let invoiceId: number + + if (result) { + const [, fiskenNumber, supplierName] = result + // invoiceId = (await trx('invoice').first('id').where({ fiskenNumber})).id + invoiceId = (await trx('invoice').first('id').where({ fiskenNumber }))?.id + + if (!invoiceId) { + let supplierId = (await trx('supplier').first('id').where('name', supplierName))?.id + + if (!supplierId) { + supplierId = (await trx('supplier').insert({ name: supplierName, supplierTypeId: 1 }).returning('id'))[0] + ?.id + } + + invoiceId = ( + await trx('invoice').insert({ financialYearId: currentYear.id, fiskenNumber, supplierId }).returning('id') + )[0]?.id + } + } + + if (invoiceId && currentInvoiceId) { + throw new Error('invoiceId and currentInvoiceId') + } const transactionId = ( await trx('transaction') .insert({ entryId: currentEntryId, ...transaction, + invoiceId: invoiceId || currentInvoiceId, }) .returning('id') )[0].id @@ -261,6 +293,26 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod let journalId = await getJournalId(journal) + // Fisken - Fakturajournal 1248 + // Fisken - Utbetalningsjournal 1056 + + // Levfakt EURO FINANS AB (5) / Levfakt Per Holmberg (6) + // Levbet EURO FINANS AB (5) / Levbet Per Holmberg (6) + const rPhm = /^Lev(?:fakt|bet)\s+[^(]*\((\d+)\)$/ + + const result = rest.description.match(rPhm) + + if (result) { + currentInvoiceId = ( + await trx('invoice') + .select('invoice.*', 'supplier.name') + .innerJoin('supplier', 'invoice.supplierId', 'supplier.id') + .where('phmNumber', result[1]) + )[0]?.id + } else { + currentInvoiceId = null + } + currentEntryId = ( await trx('entry') .insert({ diff --git a/server/routes/api.ts b/server/routes/api.ts index d5bce00..6762839 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -4,6 +4,7 @@ import knex from '../lib/knex.ts' import StatusError from '../lib/status_error.ts' import accounts from './api/accounts.ts' +import invoices from './api/invoices.ts' export const FinancialYear = Type.Object({ year: Type.Number(), @@ -15,6 +16,7 @@ export type FinancialYearType = Static const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { fastify.register(accounts, { prefix: '/accounts' }) + fastify.register(invoices, { prefix: '/invoices' }) fastify.route({ url: '/financial-years', @@ -24,102 +26,6 @@ const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { }, }) - fastify.route({ - url: '/invoices', - method: 'GET', - schema: { - querystring: Type.Object({ - year: Type.Optional(Type.Number()), - supplier: Type.Optional(Type.Number()), - }), - }, - async handler(req) { - let query: { financialYearId?: number; supplierId?: number } = {} - - if (req.query.year) { - const year = await knex('financialYear').first('*').where('year', req.query.year) - - if (!year) throw new StatusError(404, `Year ${req.query.year} not found.`) - query.financialYearId = year.id - } - - if (req.query.supplier) { - query.supplierId = req.query.supplier - } - - return knex('invoice AS i') - .select('*', { - files: knex - .select(knex.raw('json_agg(files)')) - .from( - knex - .select('id', 'filename') - .from('file AS f') - .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') - .where('fi.invoiceId', knex.ref('i.id')) - .as('files'), - ), - }) - .where(query) - }, - }) - - fastify.route({ - url: '/invoices/:id', - method: 'GET', - schema: { - params: Type.Object({ - id: Type.Number(), - }), - }, - handler(req) { - return knex('invoice').first('*').where('id', req.params.id) - }, - }) - - fastify.route({ - url: '/invoices/by-supplier/:supplier', - method: 'GET', - schema: { - params: Type.Object({ - supplier: Type.Number(), - }), - }, - handler(req) { - return knex('invoice AS i') - .select('*', { - files: knex - .select(knex.raw('json_agg(files)')) - .from( - knex - .select('id', 'filename') - .from('file AS f') - .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') - .where('fi.invoiceId', knex.ref('i.id')) - .as('files'), - ), - }) - .where('supplierId', req.params.supplier) - }, - }) - - fastify.route({ - url: '/invoices/by-year/:year', - method: 'GET', - schema: { - params: Type.Object({ - year: Type.Number(), - }), - }, - async handler(req) { - const year = await knex('financialYear').first('*').where('year', req.params.year) - - if (!year) throw new StatusError(404, `Year ${req.params.year} not found.`) - - return knex('invoice').select('*').where('financialYearId', year.id) - }, - }) - fastify.route({ url: '/objects', method: 'GET', diff --git a/server/routes/api/invoices.ts b/server/routes/api/invoices.ts new file mode 100644 index 0000000..722d40b --- /dev/null +++ b/server/routes/api/invoices.ts @@ -0,0 +1,127 @@ +import _ from 'lodash' +import { Type, type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox' +import knex from '../../lib/knex.ts' +import StatusError from '../../lib/status_error.ts' + +const apiRoutes: FastifyPluginCallbackTypebox = (fastify, _, done) => { + fastify.route({ + url: '/', + method: 'GET', + schema: { + querystring: Type.Object({ + year: Type.Optional(Type.Number()), + supplier: Type.Optional(Type.Number()), + }), + }, + async handler(req) { + let query: { financialYearId?: number; supplierId?: number } = {} + + if (req.query.year) { + const year = await knex('financialYear').first('*').where('year', req.query.year) + + if (!year) throw new StatusError(404, `Year ${req.query.year} not found.`) + query.financialYearId = year.id + } + + if (req.query.supplier) { + query.supplierId = req.query.supplier + } + + return knex('invoice AS i') + .select('i.*', 'fy.year', { + files: knex + .select(knex.raw('json_agg(files)')) + .from( + knex + .select('id', 'filename') + .from('file AS f') + .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') + .where('fi.invoiceId', knex.ref('i.id')) + .as('files'), + ), + transactions: knex + .select(knex.raw('json_agg(transactions)')) + .from(knex.select('*').from('transaction AS t').where('t.invoice_id', knex.ref('i.id')).as('transactions')), + }) + .leftOuterJoin('financialYear AS fy', 'i.financialYearId', 'fy.id') + .where(query) + }, + }) + + fastify.route({ + url: '/:id', + method: 'GET', + schema: { + params: Type.Object({ + id: Type.Number(), + }), + }, + handler(req) { + return knex('invoice AS i') + .first('i.*', 'fy.year', { + files: knex + .select(knex.raw('json_agg(files)')) + .from( + knex + .select('id', 'filename') + .from('file AS f') + .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') + .where('fi.invoiceId', knex.ref('i.id')) + .as('files'), + ), + transactions: knex + .select(knex.raw('json_agg(transactions)')) + .from(knex.select('*').from('transaction AS t').where('t.invoice_id', knex.ref('i.id')).as('transactions')), + }) + .leftOuterJoin('financialYear AS fy', 'i.financialYearId', 'fy.id') + .where('i.id', req.params.id) + }, + }) + + fastify.route({ + url: '/by-supplier/:supplier', + method: 'GET', + schema: { + params: Type.Object({ + supplier: Type.Number(), + }), + }, + handler(req) { + return knex('invoice AS i') + .select('*', { + files: knex + .select(knex.raw('json_agg(files)')) + .from( + knex + .select('id', 'filename') + .from('file AS f') + .innerJoin('filesToInvoice AS fi', 'f.id', 'fi.fileId') + .where('fi.invoiceId', knex.ref('i.id')) + .as('files'), + ), + }) + .where('supplierId', req.params.supplier) + }, + }) + + fastify.route({ + url: '/by-year/:year', + method: 'GET', + schema: { + params: Type.Object({ + year: Type.Number(), + }), + }, + async handler(req) { + const year = await knex('financialYear').first('*').where('year', req.params.year) + + if (!year) throw new StatusError(404, `Year ${req.params.year} not found.`) + + return knex('invoice').select('*').where('financialYearId', year.id) + }, + }) + + done() +} + +export default apiRoutes