Skip to content

Commit 0e941fe

Browse files
feat: render invoices with typst (#5)
* feat: render invoices with typst * chore: update pnpm-lock.yaml * feat: add typst node rendering * chore: update dependencies * feat: evict typst cache * fix: render opened invoice on send * fix: workaround for broken 'en' 'short' pattern * chore: cleanup * fix: await setInvoiceStatus on sendReceipt * chore: remove double entry in package.json * chore: fix package.json merge error * feat(InvoicePage): remove pdf extension from page title * chore: remove html2pdf.js dependency * fix: dictionary might not contain quantityUnit * feat(InvoicePage): hide payment button when no payment methods available
1 parent eb2b924 commit 0e941fe

File tree

16 files changed

+1270
-95
lines changed

16 files changed

+1270
-95
lines changed

packages/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@modular-api/fastify-checkout": "0.5.5",
5555
"@modular-api/fastify-oidc": "0.9.13",
5656
"@mollie/api-client": "4.4.0",
57+
"@myriaddreamin/typst-ts-node-compiler": "0.7.0-rc2",
5758
"@slimfact/app": "workspace:*",
5859
"@slimfact/tools": "workspace:*",
5960
"@trpc/server": "11.9.0",
@@ -80,6 +81,7 @@
8081
"@iconify-json/fa6-solid": "1.2.4",
8182
"@iconify-json/flagpack": "1.2.7",
8283
"@iconify-json/mdi": "1.2.3",
84+
"@myriaddreamin/typst.ts": "0.7.0-rc2",
8385
"@playwright/test": "1.58.0",
8486
"@types/bcryptjs": "3.0.0",
8587
"@types/oidc-provider": "9.5.0",

packages/api/src/kysely/seeds/fake/generateData.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const createInvoiceLine = () => ({
4040
listPriceIncludesTax: true,
4141
quantity: 1,
4242
quantityPerMille: false,
43+
quantityUnit: null,
4344
taxRate: 21
4445
})
4546

packages/api/src/trpc/admin/invoices.ts

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,22 @@ import { invoice as invoiceValidation } from '../../zod/invoice.js'
66
import { db } from '../../kysely/index.js'
77
import handlebars from 'handlebars'
88
import env from '@vitrify/tools/env'
9-
import { Readable } from 'stream'
109
import { Invoice } from '@modular-api/fastify-checkout'
1110
import { addDays } from 'date-fns'
1211
import { PaymentMethod, InvoiceStatus } from '@modular-api/fastify-checkout'
1312
import { emailTemplates } from '../../templates/email/index.js'
13+
import {
14+
type TypstInvoiceTemplates,
15+
renderTypstInvoice
16+
} from '@slimfact/tools/typst'
17+
import typstLang from '@slimfact/tools/templates/invoice/lang.typ?raw'
18+
import typstInternal from '@slimfact/tools/templates/invoice/internal.typ?raw'
19+
import { NodeCompiler } from '@myriaddreamin/typst-ts-node-compiler'
20+
// import { $typst } from '@myriaddreamin/typst.ts';
21+
const $typst = NodeCompiler.create({})
22+
const templates = {
23+
default: import('@slimfact/tools/templates/invoice/default.typ?raw')
24+
}
1425

1526
const host = env.read('API_HOST') || env.read('VITE_API_HOST')
1627
const slimfactDownloaderHost =
@@ -76,26 +87,46 @@ const composeEmail = ({
7687
}
7788

7889
export const downloadPdf = async (invoice: Invoice) => {
79-
let filename
80-
let pdf
90+
let typstTemplate
8191
try {
82-
const protocol = 'https' // Dev server does not use https
83-
const pdfResponse = await fetch(
84-
`${protocol}://${slimfactDownloaderHost}/?uuid=${invoice.uuid}&host=${host}`
85-
)
86-
87-
const header = pdfResponse.headers.get('Content-Disposition')
88-
const parts = header!.split(';')
89-
filename = parts[1].split('=')[1]
90-
// @ts-expect-error body does not match type
91-
if (pdfResponse.body) pdf = Readable.fromWeb(pdfResponse.body)
92-
return { success: true as const, filename, pdf }
92+
typstTemplate = (
93+
await templates[(invoice.template as TypstInvoiceTemplates) ?? 'default']
94+
).default
9395
} catch (e) {
94-
if (import.meta.env.DEBUG) console.error(e)
95-
console.error('Failed to download PDF')
96-
return { success: false as const }
96+
console.log(e)
97+
throw new Error(`No template named "${invoice.template}`)
98+
}
99+
100+
const result = await renderTypstInvoice({
101+
$typst,
102+
invoice,
103+
typstInternal,
104+
typstLang,
105+
typstTemplate,
106+
options: {
107+
export: 'pdf',
108+
includeTax: [InvoiceStatus.BILL, InvoiceStatus.RECEIPT].includes(
109+
invoice.status
110+
),
111+
pageSize: 'a4'
112+
}
113+
})
114+
$typst.evictCache(10)
115+
116+
if (result.success) {
117+
return {
118+
success: true,
119+
pdf: result.pdf,
120+
filename: result.filename
121+
}
122+
}
123+
124+
return {
125+
success: false,
126+
errorMessage: result.errorMessage
97127
}
98128
}
129+
99130
export const adminInvoiceRoutes = ({
100131
fastify,
101132
procedure
@@ -353,7 +384,6 @@ export const adminInvoiceRoutes = ({
353384
.executeTakeFirst()) || {}
354385

355386
const currentDate = new Date()
356-
357387
const numberPrefix = handlebars.compile(
358388
invoice.numberPrefixTemplate
359389
)({
@@ -386,17 +416,17 @@ export const adminInvoiceRoutes = ({
386416
emailBody
387417
})
388418

389-
const pdfResult = await downloadPdf(invoice)
419+
const pdfResult = await downloadPdf(result.invoice)
390420
const attachments = []
391421
if (pdfResult.success)
392422
attachments.push({
393423
filename: pdfResult.filename,
394424
content: pdfResult.pdf
395425
})
396426
await fastify.mailer?.sendMail({
397-
from: `${invoice.companyDetails.name} <noreply@slimfact.app>`,
398-
replyTo: invoice.companyDetails.email,
399-
to: invoice.clientDetails.email,
427+
from: `${result.invoice.companyDetails.name} <noreply@slimfact.app>`,
428+
replyTo: result.invoice.companyDetails.email,
429+
to: result.invoice.clientDetails.email,
400430
bcc: emailBcc,
401431
subject,
402432
html: body,
@@ -633,7 +663,7 @@ export const adminInvoiceRoutes = ({
633663
try {
634664
if (fastify.checkout?.invoiceHandler) {
635665
const { id, emailSubject, emailBody } = input
636-
fastify.checkout.invoiceHandler.setInvoiceStatus({
666+
await fastify.checkout.invoiceHandler.setInvoiceStatus({
637667
id,
638668
status: InvoiceStatus.RECEIPT
639669
})

packages/api/vitrify.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export default async function ({
4747
'playwright',
4848
'svgo',
4949
'compress-tag',
50-
'short-uuid'
50+
'short-uuid',
51+
'@slimfact/tools',
52+
'@myriaddreamin/typst-ts-node-compiler',
53+
'@myriaddreamin/typst-ts'
5154
]
5255
},
5356
manualChunks: ['api.config', 'zod', 'date-fns', 'types']

packages/app/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,16 @@
5454
"@modular-api/fastify-cart": "0.3.10",
5555
"@modular-api/fastify-checkout": "0.5.4",
5656
"@modular-api/quasar-components": "0.3.29",
57+
"@myriaddreamin/typst-ts-renderer": "0.7.0-rc2",
58+
"@myriaddreamin/typst-ts-web-compiler": "0.7.0-rc2",
59+
"@myriaddreamin/typst.ts": "0.7.0-rc2",
60+
"@myriaddreamin/typst.vue3": "0.7.0-rc2",
5761
"@pinia/colada": "0.21.2",
5862
"@quasar/extras": "1.17.0",
5963
"@quasar/quasar-ui-qcalendar": "4.1.2",
6064
"@simsustech/quasar-components": "0.11.25",
6165
"@slimfact/api": "link:../api",
66+
"@slimfact/shared": "link:../shared",
6267
"@trpc/client": "11.9.0",
6368
"@trpc/server": "11.9.0",
6469
"@typescript-eslint/eslint-plugin": "8.54.0",
@@ -80,7 +85,6 @@
8085
"eslint-plugin-prettier-vue": "5.0.0",
8186
"eslint-plugin-vue": "10.7.0",
8287
"fastify": "5.7.2",
83-
"html2pdf.js": "0.14.0",
8488
"icon-gen": "5.0.0",
8589
"kysely": "0.28.10",
8690
"lionel-oauth-client": "npm:@stefanvh/lionel-oauth-client@^0.7.0",
950 KB
Binary file not shown.
27.1 MB
Binary file not shown.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<template>
2+
<div v-if="svg" class="[&>svg]:(border-1px border-solid)" v-html="svg" />
3+
</template>
4+
5+
<script setup lang="ts">
6+
import { onMounted, ref, toRefs, watch } from 'vue'
7+
import { type InvoicePayload } from '@modular-api/fastify-checkout'
8+
import { InvoiceStatus } from '@modular-api/fastify-checkout/types'
9+
import { $typst } from '@myriaddreamin/typst.ts'
10+
import typstLang from '@slimfact/tools/templates/invoice/lang.typ?raw'
11+
import typstInternal from '@slimfact/tools/templates/invoice/internal.typ?raw'
12+
import { useLang } from 'src/lang'
13+
import {
14+
SweetCompileOptions,
15+
SweetRenderOptions
16+
} from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
17+
import { exportFile } from 'quasar'
18+
import { renderTypstInvoice } from '@slimfact/tools/typst'
19+
20+
const templates = {
21+
default: import('@slimfact/tools/templates/invoice/default.typ?raw')
22+
}
23+
24+
export interface Props {
25+
modelValue: InvoicePayload
26+
template?: 'default'
27+
pageSize?: 'a4' | 'us-letter'
28+
includeTax?: boolean
29+
}
30+
const props = withDefaults(defineProps<Props>(), {
31+
template: 'default',
32+
pageSize: 'a4'
33+
})
34+
const { modelValue, includeTax, pageSize, template } = toRefs(props)
35+
const lang = useLang()
36+
37+
const svg = ref<string>()
38+
const typstTemplate = ref()
39+
40+
const renderOptions = ref<SweetRenderOptions | SweetCompileOptions>({})
41+
42+
watch(
43+
() => typstTemplate.value,
44+
async (newVal, _) => {
45+
const result = await renderTypstInvoice({
46+
$typst,
47+
invoice: modelValue.value,
48+
typstTemplate: newVal,
49+
typstLang,
50+
typstInternal,
51+
options: {
52+
export: 'svg',
53+
includeTax: includeTax.value,
54+
pageSize: pageSize.value,
55+
typstCompilerUrl: '/typst/typst_ts_web_compiler_bg.wasm',
56+
typstRendererUrl: '/typst/typst_ts_renderer_bg.wasm'
57+
}
58+
})
59+
if (result.success) svg.value = result.svg
60+
}
61+
)
62+
63+
let readyPromiseResolve: (value?: unknown) => void
64+
const ready = new Promise((resolve) => {
65+
readyPromiseResolve = resolve
66+
})
67+
68+
const downloadPdf = async () => {
69+
await ready
70+
const result = await renderTypstInvoice({
71+
$typst,
72+
invoice: modelValue.value,
73+
typstTemplate: typstTemplate.value,
74+
typstLang,
75+
typstInternal,
76+
options: {
77+
export: 'pdf',
78+
includeTax: includeTax.value,
79+
pageSize: pageSize.value,
80+
typstCompilerUrl: '/typst/typst_ts_web_compiler_bg.wasm',
81+
typstRendererUrl: '/typst/typst_ts_renderer_bg.wasm'
82+
}
83+
})
84+
if (result.success && result.pdf) {
85+
exportFile(result.filename ?? 'invoice.pdf', result.pdf)
86+
}
87+
// const pdf = await $typst.pdf({
88+
// ...(renderOptions.value as SweetCompileOptions)
89+
// })
90+
91+
// let filename = 'invoice.pdf'
92+
// if (modelValue.value && modelValue.value?.status === InvoiceStatus.RECEIPT) {
93+
// filename = `${lang.value.receipt.receipt} ${modelValue.value.companyDetails.name || modelValue.value.companyDetails.contactPersonName}.pdf`
94+
// } else if (
95+
// modelValue.value &&
96+
// modelValue.value?.status === InvoiceStatus.BILL
97+
// ) {
98+
// filename = `${lang.value.bill.bill} ${modelValue.value.companyDetails.name || modelValue.value.companyDetails.contactPersonName}.pdf`
99+
// } else if (
100+
// modelValue.value &&
101+
// modelValue.value?.status === InvoiceStatus.CONCEPT
102+
// ) {
103+
// filename = `${modelValue.value.companyDetails.name}
104+
// ${lang.value.invoice.status.concept}
105+
// .pdf`
106+
// } else if (modelValue.value) {
107+
// filename = `${modelValue.value.date} ${modelValue.value.companyDetails.name}
108+
// ${lang.value.invoice.invoice}
109+
// ${modelValue.value.numberPrefix}${modelValue.value.number}.pdf`
110+
// }
111+
112+
// if (pdf) {
113+
// exportFile(filename, pdf)
114+
// }
115+
}
116+
117+
defineExpose({
118+
downloadPdf
119+
})
120+
121+
onMounted(async () => {
122+
typstTemplate.value = await (await templates.default).default
123+
$typst.setCompilerInitOptions({
124+
beforeBuild: [],
125+
getModule: () => '/typst/typst_ts_web_compiler_bg.wasm'
126+
// 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm'
127+
})
128+
129+
$typst.setRendererInitOptions({
130+
beforeBuild: [],
131+
getModule: () => '/typst/typst_ts_renderer_bg.wasm'
132+
// 'https://cdn.jsdelivr.net/npm/@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm'
133+
})
134+
readyPromiseResolve()
135+
})
136+
</script>

0 commit comments

Comments
 (0)