]> Git — Sourcephile - tmp/julm/literate-invoice.git/blob - src/Literate/Invoice/HTML.hs
feat/role(Database): init
[tmp/julm/literate-invoice.git] / src / Literate / Invoice / HTML.hs
1 {-# LANGUAGE OverloadedLists #-}
2 {-# LANGUAGE PolyKinds #-}
3 -- For QuantFact
4 {-# LANGUAGE UndecidableInstances #-}
5 {-# OPTIONS_GHC -Wno-orphans #-}
6
7 module Literate.Invoice.HTML where
8
9 import Data.List qualified as List
10 import Data.Map.Strict qualified as Map
11 import Data.Time qualified as Time
12 import System.FilePath.Posix qualified as File
13 import Text.Printf qualified as Printf
14 import Prelude qualified
15
16 import Literate.Accounting qualified as Accounting
17 import Literate.Accounting.Amount
18 import Literate.Accounting.Quantity
19 import Literate.Accounting.Unit
20 import Literate.Database
21 import Literate.Document.HTML
22 import Literate.Document.HTML qualified as HTML
23 import Literate.Document.Table qualified as Doc
24 import Literate.Document.Type qualified as Doc
25 import Literate.Invoice
26 import Literate.Organization
27 import Literate.Prelude
28 import Paths_literate_business qualified as Self
29
30 class HTMLIOable a where
31 htmlIO :: a -> IO Html
32
33 instance Doc.ToBlock InvoiceType where
34 toBlock = \case
35 InvoiceTypeProForma -> "pro forma"
36 InvoiceTypeSale -> "sale"
37 InvoiceTypeVoucher -> "voucher"
38 instance Doc.ToBlock InvoiceItemType where
39 toBlock = \case
40 InvoiceItemTypeItem -> "item"
41 InvoiceItemTypeService -> "service"
42
43 instance (ToMarkup (Quantity qf), UnitShowS unit) => ToMarkup (Amount qf unit) where
44 toMarkup Amount{..} =
45 (amountQuantity & HTML.toMarkup)
46 <> (if null unit then "" else "\x202F" <> (unit & toHtml))
47 where
48 unit = unitShow @unit
49 instance (Doc.ToInline (Quantity qf), UnitShowS unit) => Doc.ToInline (Amount qf unit) where
50 toInline Amount{..} =
51 (amountQuantity & Doc.toInline)
52 <> (if null unit then "" else "\x202F" <> (unit & Doc.toInline))
53 where
54 unit = unitShow @unit
55 instance (Doc.ToBlock (Quantity qf), QuantFact qf, UnitShowS unit) => Doc.ToBlock (Amount qf unit) where
56 toBlock x = Doc.BlockPara $ x & Doc.toInline
57
58 instance QuantFact qf => ToMarkup (Quantity qf) where
59 toMarkup qty = do
60 toHtml $
61 qty
62 & quantityToRatio @qf
63 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
64 & ( `Printf.formatArg`
65 Printf.FieldFormat
66 { fmtAdjust = Nothing
67 , fmtAlternate = False
68 , fmtChar = 'f'
69 , fmtModifiers = ""
70 , fmtPrecision =
71 Just $
72 quantisationFactor @qf
73 & (Prelude.fromIntegral :: _ -> Double)
74 & Prelude.logBase 10
75 & Prelude.floor
76 , fmtSign = Nothing
77 , fmtWidth = Nothing
78 }
79 )
80 & ($ "")
81 instance QuantFact qf => Doc.ToInline (Quantity qf) where
82 toInline qty = Doc.toInline do
83 qty
84 & quantityToRatio @qf
85 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
86 & ( `Printf.formatArg`
87 Printf.FieldFormat
88 { fmtAdjust = Nothing
89 , fmtAlternate = False
90 , fmtChar = 'f'
91 , fmtModifiers = ""
92 , fmtPrecision =
93 Just $
94 quantisationFactor @qf
95 & (Prelude.fromIntegral :: _ -> Double)
96 & Prelude.logBase 10
97 & Prelude.floor
98 , fmtSign = Nothing
99 , fmtWidth = Nothing
100 }
101 )
102 & ($ "")
103 instance QuantFact qf => Doc.ToBlock (Quantity qf) where
104 toBlock x = Doc.BlockPara $ x & Doc.toInline
105
106 invoiceIdInline ::
107 forall entId invId.
108 Get (Organization entId) entId =>
109 Get (Entity entId) entId =>
110 Enum invId =>
111 Doc.ToInline entId =>
112 Invoice entId invId ->
113 Doc.Inline
114 invoiceIdInline inv =
115 [ "org"
116 , inv
117 & invoiceRecipient
118 & get @(Organization entId)
119 & orgEntity
120 & get @(Entity entId)
121 & entityId
122 & Doc.toInline
123 , case inv & invoiceType of
124 InvoiceTypeProForma -> "pro-forma"
125 InvoiceTypeSale -> "sale"
126 InvoiceTypeVoucher -> "voucher"
127 , inv & invoiceId & fromEnum & show & Doc.toInline
128 ]
129 & Doc.Inlines
130
131 instance
132 ( Get (Organization entId) entId
133 , Get (Entity entId) entId
134 , Enum entId
135 , Enum invId
136 , Doc.ToInline entId
137 ) =>
138 HTMLIOable (Invoice entId invId)
139 where
140 htmlIO inv = do
141 -- FixMe(portability): this absolute path is not portable out of my system
142 dataPath <- Self.getDataDir <&> File.normalise
143 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
144 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
145 let invSummary :: [InvoiceItem _ _] =
146 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
147 & foldMap (foldMap pure)
148 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
149 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
150 let invTitle :: Doc.Inline =
151 Doc.toInline $
152 List.intersperse " - " $
153 mconcat
154 [ case inv
155 & invoiceIssuer
156 & get @(Organization entId)
157 & orgEntity
158 & get @(Entity entId)
159 & entityName of
160 Nothing -> []
161 Just n -> [Doc.toInline n]
162 , [inv & invoiceEmittedOn & Doc.toInline]
163 , ["Invoice #" <> (inv & invoiceIdInline)]
164 , inv & invoiceOrders
165 ]
166 return $
167 toHtml $
168 dataPath :=
169 Doc.Document
170 { documentTitle = invTitle
171 , documentAttachments =
172 [ "css" :=
173 [ "styles/Document.css"
174 , "styles/Invoice.css"
175 , "styles/List.css"
176 , "styles/Paper.css"
177 , "styles/Table.css"
178 ]
179 ]
180 , documentPages =
181 [
182 [ Doc.page
183 { Doc.pageSection = Just $ invTitle
184 , Doc.pageContent =
185 Doc.flex
186 { Doc.flexDirection = Doc.FlexDirectionColumn
187 , Doc.flexGap = 0.5 & Doc.cm
188 , Doc.flexItems =
189 [ "Invoice" & Doc.classes ["title"]
190 , Doc.section
191 ( [ ["InvoiceIdentifier" := inv & invoiceIdInline & Doc.toBlock]
192 , ["InvoiceType" := inv & invoiceType & Doc.toBlock]
193 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
194 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
195 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
196 ]
197 & mconcat
198 & Doc.Dict
199 & Doc.toBlock
200 )
201 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
202 & Doc.BlockDiv
203 , Doc.section
204 [ Organization
205 { orgEntity = toEnum 0 :: entId -- Explanation: ununused placeholder
206 , orgParts =
207 [ "Seller" := inv & invoiceIssuer & get
208 , "Buyer" := inv & invoiceRecipient & get
209 ]
210 }
211 & Doc.toBlock
212 ]
213 & \x ->
214 x{Doc.containerClasses = ["invoice-from-to"]}
215 & Doc.BlockDiv
216 , Doc.section
217 [ "Grand totals" & Doc.classes ["title"]
218 , Doc.table
219 { Doc.tableHeads =
220 Just
221 [ "Time frame"
222 , "Workload"
223 , "To pay (excl. taxes)"
224 ]
225 , Doc.tableRows =
226 [
227 [ Doc.tableCell
228 { Doc.tableCellJustify = Doc.JustifyCenter
229 , Doc.tableCellContent =
230 invSummary
231 <&> invoiceItemPeriod
232 & List.foldr1
233 ( \x y ->
234 Period
235 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
236 , periodEnd = max (x & periodEnd) (y & periodEnd)
237 }
238 )
239 & \period ->
240 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
241 , period & periodEnd & Doc.toInline & Doc.BlockPara
242 ]
243 }
244 , Doc.tableCell
245 { Doc.tableCellJustify = Doc.JustifyCenter
246 , Doc.tableCellContent =
247 sumAmounts
248 [ itm & invoiceItemQuantity
249 | itm <- invSummary
250 ]
251 & fromMaybe 0
252 & Doc.toBlock
253 }
254 , Doc.tableCell
255 { Doc.tableCellJustify = Doc.JustifyCenter
256 , Doc.tableCellContent =
257 sumAmounts
258 [ itm & invoiceItemTotal
259 | itm <- invSummary
260 ]
261 & fromMaybe 0
262 & Doc.toBlock
263 }
264 ]
265 ]
266 }
267 & Doc.toBlock
268 ]
269 & Doc.BlockDiv
270 , Doc.section
271 [ "Mandatory legal notices" & Doc.classes ["title"]
272 , Doc.List
273 [ case mention of
274 InvoiceMentionTVANonApplicable ->
275 -- "TVA non applicable, art. 293 B du code général des impôts."
276 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
277 InvoiceMentionIndemnitéForfaitaire ->
278 "—" :=
279 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
280 "Fixed compensation for recovery costs in case of late payment: "
281 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
282 & Doc.toInline
283 ]
284 & Doc.BlockPara
285 InvoiceMentionIndemnitéTaux rate ->
286 "—" :=
287 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
288 "Late payment penalty rate (applicable from "
289 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
290 , -- , "\x202F: "
291 "): "
292 , rate & Doc.toInline
293 , -- , " × montant impayé × nombre de jours de retard / 365.25"
294 " × unpaid amount × number of days late / 365.25"
295 ]
296 & Doc.BlockPara
297 | mention <- inv & invoiceMentions
298 ]
299 & Doc.toBlock
300 ]
301 & Doc.BlockDiv
302 ]
303 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
304 }
305 & Doc.toBlock
306 }
307 ]
308 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
309 Doc.page
310 { Doc.pageSection = Just $ invTitle <> " — Summary"
311 , Doc.pageContent =
312 Doc.flex
313 { Doc.flexDirection = Doc.FlexDirectionColumn
314 , Doc.flexGap = 0.5 & Doc.cm
315 , Doc.flexItems =
316 [ Doc.flexItem
317 { Doc.flexItemContent =
318 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
319 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
320 ]
321 & Doc.toBlock
322 ,
323 [ Doc.table
324 { Doc.tableTemplate =
325 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
326 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
327 , Doc.LengthRelative $ 1 & Doc.fr
328 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
329 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
330 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
331 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
332 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
333 ]
334 , Doc.tableRowsEvenOdd = True
335 , Doc.tableHeads =
336 Just
337 [ "#"
338 , "Type"
339 , "Scope"
340 , "Action"
341 , "Time frame"
342 , Doc.tableCell
343 { Doc.tableCellContent =
344 [ "Rate" & Doc.BlockPara
345 , "(excl.\xA0taxes.)" & Doc.BlockPara
346 ]
347 , Doc.tableCellJustify = Doc.JustifyCenter
348 }
349 , "Quantity"
350 , Doc.tableCell
351 { Doc.tableCellContent =
352 [ "Total" & Doc.BlockPara
353 , "(excl.\xA0taxes.)" & Doc.BlockPara
354 ]
355 , Doc.tableCellJustify = Doc.JustifyCenter
356 }
357 ]
358 , Doc.tableRows =
359 [ [ Doc.tableCell
360 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
361 , Doc.tableCellJustify = Doc.JustifyEnd
362 }
363 , Doc.tableCell
364 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
365 , Doc.tableCellJustify = Doc.JustifyCenter
366 }
367 , Doc.tableCell
368 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
369 , Doc.tableCellJustify = Doc.JustifyStart
370 }
371 , Doc.tableCell
372 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
373 , Doc.tableCellJustify = Doc.JustifyStart
374 }
375 , Doc.tableCell
376 { Doc.tableCellContent =
377 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
378 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
379 ]
380 , Doc.tableCellJustify = Doc.JustifyStart
381 }
382 , Doc.tableCell
383 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
384 , Doc.tableCellJustify = Doc.JustifyEnd
385 }
386 , Doc.tableCell
387 { Doc.tableCellContent =
388 [ invItem & invoiceItemQuantity & Doc.toBlock
389 , let (qtyPercent, _actualRate) =
390 Accounting.fraction
391 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
392 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
393 in qtyPercent
394 & Accounting.quantityToRational
395 & (fromRational :: _ -> Double)
396 & (Printf.printf "(%02f%%)" :: _ -> String)
397 & Doc.toBlock
398 ]
399 , Doc.tableCellJustify = Doc.JustifyEnd
400 }
401 , Doc.tableCell
402 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
403 , Doc.tableCellJustify = Doc.JustifyEnd
404 }
405 ]
406 | (itemCount, invItem) <- invItemsChunk
407 ]
408 }
409 & Doc.toBlock
410 ]
411 ]
412 }
413 ]
414 }
415 & Doc.toBlock
416 & Doc.section
417 & ( \x ->
418 x{Doc.containerClasses = ["invoice-summary"]}
419 )
420 & Doc.toBlock
421 }
422 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
423 Doc.page
424 { Doc.pageSection = Just $ invTitle <> " — Details"
425 , Doc.pageContent =
426 Doc.flex
427 { Doc.flexDirection = Doc.FlexDirectionColumn
428 , Doc.flexGap = 0.5 & Doc.cm
429 , Doc.flexItems =
430 [ Doc.flexItem
431 { Doc.flexItemContent =
432 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
433 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
434 ]
435 & Doc.toBlock
436 , Doc.table
437 { Doc.tableTemplate =
438 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
439 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
440 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
441 , Doc.LengthRelative $ 1 & Doc.fr
442 , Doc.LengthRelative $ 1 & Doc.fr
443 , Doc.LengthAbsolute $ 5 & Doc.cm
444 , Doc.LengthRelative $ 2 & Doc.fr
445 ]
446 , Doc.tableRowsEvenOdd = True
447 , Doc.tableHeads =
448 Just
449 [ "#"
450 , "Date"
451 , "Duration"
452 , "Scope"
453 , "Action"
454 , "References"
455 , "Description"
456 ]
457 , Doc.tableRows =
458 [ [ Doc.tableCell
459 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
460 , Doc.tableCellJustify = Doc.JustifyEnd
461 }
462 , Doc.tableCell
463 { Doc.tableCellContent = work & workDate & Doc.toBlock
464 , Doc.tableCellJustify = Doc.JustifyCenter
465 }
466 , Doc.tableCell
467 { Doc.tableCellContent =
468 work & workDuration & Doc.toBlock
469 , Doc.tableCellJustify = Doc.JustifyEnd
470 }
471 , Doc.tableCell
472 { Doc.tableCellContent = work & workScope & pathToBlock
473 , Doc.tableCellJustify = Doc.JustifyStart
474 }
475 , Doc.tableCell
476 { Doc.tableCellContent = work & workAction & pathToBlock
477 , Doc.tableCellJustify = Doc.JustifyStart
478 }
479 , Doc.tableCell
480 { Doc.tableCellContent =
481 [ ref & Doc.toInline
482 | ref <- work & workReferences
483 ]
484 & Doc.toInline
485 & Doc.toBlock
486 , Doc.tableCellJustify = Doc.JustifyStart
487 }
488 , Doc.tableCell
489 { Doc.tableCellContent = work & workDescription & Doc.toBlock
490 , Doc.tableCellJustify = Doc.JustifyStart
491 }
492 ]
493 | (workCount, work) <- worksChunk
494 ]
495 }
496 & Doc.toBlock
497 ]
498 }
499 ]
500 }
501 & Doc.toBlock
502 & Doc.section
503 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
504 & Doc.toBlock
505 }
506 ]
507 & mconcat
508 & ol1
509 & \l ->
510 l & List.map \(pageIdx, pag) ->
511 pag
512 { Doc.pageOrientation = Doc.PageOrientationPortrait
513 , Doc.pageSize = Doc.PageSizeA4
514 , Doc.pageNumber = Just pageIdx
515 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
516 , Doc.pageSide =
517 Just $
518 if pageIdx & even
519 then Doc.PageSideLeft
520 else Doc.PageSideRight
521 }
522 }
523 where
524 pathToBlock segs =
525 [ seg & Doc.toInline
526 | seg <- segs
527 ]
528 & List.intersperse " / "
529 & Doc.toInline
530 & Doc.toBlock
531
532 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
533 invoiceSummary invRates works =
534 Map.unionsWith
535 ( Map.unionWith
536 ( \x y ->
537 InvoiceItem
538 { invoiceItemScope = x & invoiceItemScope
539 , invoiceItemAction = x & invoiceItemAction
540 , invoiceItemPeriod =
541 Period
542 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
543 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
544 }
545 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
546 , invoiceItemType = x & invoiceItemType
547 , invoiceItemRate = x & invoiceItemRate
548 }
549 )
550 )
551 [ Map.singleton (work & workScope) $
552 Map.singleton (work & workAction) $
553 InvoiceItem
554 { invoiceItemScope = work & workScope
555 , invoiceItemAction = work & workAction
556 , invoiceItemType = InvoiceItemTypeService
557 , invoiceItemQuantity = work & workDuration
558 , invoiceItemPeriod =
559 Period
560 { periodBeginning = work & workDate
561 , periodEnd = work & workDate
562 }
563 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))
564 }
565 | work <- works
566 ]