link transactions to invoices
This commit is contained in:
parent
abc561258a
commit
ee25824424
@ -1,15 +0,0 @@
|
||||
meta {
|
||||
name: Invoice
|
||||
type: http
|
||||
seq: 7
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
20
.bruno/BRF/api-invoice--id.bru
Normal file
20
.bruno/BRF/api-invoice--id.bru
Normal 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
|
||||
}
|
||||
@ -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]
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
102
client/public/components/invoice_page.tsx
Normal file
102
client/public/components/invoice_page.tsx
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
16
client/public/utils/format_number.ts
Normal file
16
client/public/utils/format_number.ts
Normal 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)
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
127
server/routes/api/invoices.ts
Normal file
127
server/routes/api/invoices.ts
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user