link transactions to invoices

This commit is contained in:
Linus Miller 2025-11-28 10:49:39 +01:00
parent abc561258a
commit ee25824424
12 changed files with 345 additions and 122 deletions

View File

@ -1,15 +0,0 @@
meta {
name: Invoice
type: http
seq: 7
}
get {
url: {{base_url}}/
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@ -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
}

View File

@ -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]

View File

@ -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 })
}
}

View File

@ -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 (
<section>
<Head>
<title> : Invoice : {invoice?.id}</title>
</Head>
<h1>Invoice</h1>
<table>
<thead>
<tr>
<th>what</th>
<th>who</th>
</tr>
</thead>
{invoice && (
<tbody>
<tr>
<td>ID</td>
<td>{invoice.id}</td>
</tr>
<tr>
<td>Date</td>
<td>{invoice.invoiceDate}</td>
</tr>
<tr>
<td>Due Date</td>
<td>{invoice.dueDate}</td>
</tr>
<tr>
<td>Fisken</td>
<td>{invoice.fiskenNumber}</td>
</tr>
<tr>
<td>PHM</td>
<td>{invoice.phmNumber}</td>
</tr>
<tr>
<td>Amount</td>
<td>{invoice.amount}</td>
</tr>
<tr>
<td>Files</td>
<td>
{invoice.files?.map((file) => (
<a href={`/uploads/invoices/${file.filename}`} target='blank'>
{file.filename}
</a>
))}
</td>
</tr>
<tr>
<td>ID</td>
<td>{invoice.id}</td>
</tr>
<tr>
<td>ID</td>
<td>{invoice.id}</td>
</tr>
</tbody>
)}
</table>
<table>
<thead>
<tr>
<th>Account</th>
<th>Debit</th>
<th>Credit</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{invoice?.transactions?.map((transaction) => (
<tr>
<td>{transaction.account_number}</td>
<td>{transaction.amount >= 0 ? formatNumber(transaction.amount) : null}</td>
<td>{transaction.amount < 0 ? formatNumber(Math.abs(transaction.amount)) : null}</td>
<td>{transaction.description}</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
export default InvoicePage

View File

@ -42,7 +42,9 @@ const InvoicesPage = () => {
<tbody>
{invoices.map((invoice) => (
<tr>
<td>{invoice.id}</td>
<td>
<a href={`/invoices/${invoice.id}`}>{invoice.id}</a>
</td>
<td>{invoice.fiskenNumber}</td>
<td>{invoice.phmNumber}</td>
<td>{format(invoice.invoiceDate)}</td>
@ -51,7 +53,9 @@ const InvoicesPage = () => {
<td>{invoice.amount}</td>
<td>
{invoice.files?.map((file) => (
<a href={`/uploads/invoices/${file.filename}`}>{file.filename}</a>
<a href={`/uploads/invoices/${file.filename}`} target='_blank'>
{file.filename}
</a>
))}
</td>
</tr>

View File

@ -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',

View File

@ -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)

View File

@ -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

View File

@ -41,8 +41,9 @@ export default async function parseStream(stream: ReadableStream, decoder: Decod
const journals = new Map()
let currentEntryId: number
const details: Record<string, string> = {}
let currentInvoiceId: number
let currentYear = null
const details: Record<string, string> = {}
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({

View File

@ -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<typeof FinancialYear>
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',

View File

@ -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