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
+
+
+
+ | what |
+ who |
+
+
+ {invoice && (
+
+
+ | 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} |
+
+
+ )}
+
+
+
+
+
+ | Account |
+ Debit |
+ Credit |
+ Description |
+
+
+
+ {invoice?.transactions?.map((transaction) => (
+
+ | {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