]> Git — Sourcephile - tmp/julm/literate-invoice.git/blob - src/Literate/Invoice/HTML.hs
maint/correctness(Work): use sum type for ScopeId
[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 scopeId actionId entId invId.
108 Get (Organization entId) entId =>
109 Get (Entity entId) entId =>
110 Enum invId =>
111 Doc.ToInline entId =>
112 Invoice scopeId actionId 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 , Ord scopeId
135 , Show actionId
136 , Ord actionId
137 , Enum entId
138 , Enum invId
139 , Doc.ToInline scopeId
140 , Doc.ToInline actionId
141 , Doc.ToInline entId
142 ) =>
143 HTMLIOable (Invoice scopeId actionId entId invId)
144 where
145 htmlIO inv = do
146 -- FixMe(portability): this absolute path is not portable out of my system
147 dataPath <- Self.getDataDir <&> File.normalise
148 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
149 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
150 let invSummary :: [InvoiceItem _ _ _ _] =
151 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
152 & foldMap (foldMap pure)
153 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
154 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
155 let invTitle :: Doc.Inline =
156 Doc.toInline $
157 List.intersperse " - " $
158 mconcat
159 [ case inv
160 & invoiceIssuer
161 & get @(Organization entId)
162 & orgEntity
163 & get @(Entity entId)
164 & entityName of
165 Nothing -> []
166 Just n -> [Doc.toInline n]
167 , [inv & invoiceEmittedOn & Doc.toInline]
168 , ["Invoice #" <> (inv & invoiceIdInline)]
169 , inv & invoiceOrders
170 ]
171 return $
172 toHtml $
173 dataPath :=
174 Doc.Document
175 { documentTitle = invTitle
176 , documentAttachments =
177 [ "css" :=
178 [ "styles/Document.css"
179 , "styles/Invoice.css"
180 , "styles/List.css"
181 , "styles/Paper.css"
182 , "styles/Table.css"
183 ]
184 ]
185 , documentPages =
186 [
187 [ Doc.page
188 { Doc.pageSection = Just $ invTitle
189 , Doc.pageContent =
190 Doc.flex
191 { Doc.flexDirection = Doc.FlexDirectionColumn
192 , Doc.flexGap = 0.5 & Doc.cm
193 , Doc.flexItems =
194 [ "Invoice" & Doc.classes ["title"]
195 , Doc.section
196 ( [ ["InvoiceIdentifier" := inv & invoiceIdInline & Doc.toBlock]
197 , ["InvoiceType" := inv & invoiceType & Doc.toBlock]
198 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
199 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
200 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
201 ]
202 & mconcat
203 & Doc.Dict
204 & Doc.toBlock
205 )
206 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
207 & Doc.BlockDiv
208 , Doc.section
209 [ Organization
210 { orgEntity = toEnum 0 :: entId -- Explanation: ununused placeholder
211 , orgParts =
212 [ "Seller" := inv & invoiceIssuer & get
213 , "Buyer" := inv & invoiceRecipient & get
214 ]
215 }
216 & Doc.toBlock
217 ]
218 & \x ->
219 x{Doc.containerClasses = ["invoice-from-to"]}
220 & Doc.BlockDiv
221 , Doc.section
222 [ "Grand totals" & Doc.classes ["title"]
223 , Doc.table
224 { Doc.tableHeads =
225 Just
226 [ "Time frame"
227 , "Workload"
228 , "To pay (excl. taxes)"
229 ]
230 , Doc.tableRows =
231 [
232 [ Doc.tableCell
233 { Doc.tableCellJustify = Doc.JustifyCenter
234 , Doc.tableCellContent =
235 invSummary
236 <&> invoiceItemPeriod
237 & List.foldr1
238 ( \x y ->
239 Period
240 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
241 , periodEnd = max (x & periodEnd) (y & periodEnd)
242 }
243 )
244 & \period ->
245 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
246 , period & periodEnd & Doc.toInline & Doc.BlockPara
247 ]
248 }
249 , Doc.tableCell
250 { Doc.tableCellJustify = Doc.JustifyCenter
251 , Doc.tableCellContent =
252 sumAmounts
253 [ itm & invoiceItemQuantity
254 | itm <- invSummary
255 ]
256 & fromMaybe 0
257 & Doc.toBlock
258 }
259 , Doc.tableCell
260 { Doc.tableCellJustify = Doc.JustifyCenter
261 , Doc.tableCellContent =
262 sumAmounts
263 [ itm & invoiceItemTotal
264 | itm <- invSummary
265 ]
266 & fromMaybe 0
267 & Doc.toBlock
268 }
269 ]
270 ]
271 }
272 & Doc.toBlock
273 ]
274 & Doc.BlockDiv
275 , Doc.section
276 [ "Mandatory legal notices" & Doc.classes ["title"]
277 , Doc.List
278 [ case mention of
279 InvoiceMentionTVANonApplicable ->
280 -- "TVA non applicable, art. 293 B du code général des impôts."
281 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
282 InvoiceMentionIndemnitéForfaitaire ->
283 "—" :=
284 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
285 "Fixed compensation for recovery costs in case of late payment: "
286 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
287 & Doc.toInline
288 ]
289 & Doc.BlockPara
290 InvoiceMentionIndemnitéTaux rate ->
291 "—" :=
292 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
293 "Late payment penalty rate (applicable from "
294 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
295 , -- , "\x202F: "
296 "): "
297 , rate & Doc.toInline
298 , -- , " × montant impayé × nombre de jours de retard / 365.25"
299 " × unpaid amount × number of days late / 365.25"
300 ]
301 & Doc.BlockPara
302 | mention <- inv & invoiceMentions
303 ]
304 & Doc.toBlock
305 ]
306 & Doc.BlockDiv
307 ]
308 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
309 }
310 & Doc.toBlock
311 }
312 ]
313 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
314 Doc.page
315 { Doc.pageSection = Just $ invTitle <> " — Summary"
316 , Doc.pageContent =
317 Doc.flex
318 { Doc.flexDirection = Doc.FlexDirectionColumn
319 , Doc.flexGap = 0.5 & Doc.cm
320 , Doc.flexItems =
321 [ Doc.flexItem
322 { Doc.flexItemContent =
323 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
324 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
325 ]
326 & Doc.toBlock
327 ,
328 [ Doc.table
329 { Doc.tableTemplate =
330 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
331 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
332 , Doc.LengthRelative $ 1 & Doc.fr
333 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
334 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
335 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
336 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
337 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
338 ]
339 , Doc.tableRowsEvenOdd = True
340 , Doc.tableHeads =
341 Just
342 [ "#"
343 , "Type"
344 , "Scope"
345 , "Action"
346 , "Time frame"
347 , Doc.tableCell
348 { Doc.tableCellContent =
349 [ "Rate" & Doc.BlockPara
350 , "(excl.\xA0taxes.)" & Doc.BlockPara
351 ]
352 , Doc.tableCellJustify = Doc.JustifyCenter
353 }
354 , "Quantity"
355 , Doc.tableCell
356 { Doc.tableCellContent =
357 [ "Total" & Doc.BlockPara
358 , "(excl.\xA0taxes.)" & Doc.BlockPara
359 ]
360 , Doc.tableCellJustify = Doc.JustifyCenter
361 }
362 ]
363 , Doc.tableRows =
364 [ [ Doc.tableCell
365 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
366 , Doc.tableCellJustify = Doc.JustifyEnd
367 }
368 , Doc.tableCell
369 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
370 , Doc.tableCellJustify = Doc.JustifyCenter
371 }
372 , Doc.tableCell
373 { Doc.tableCellContent = invItem & invoiceItemScope & Doc.toInline & Doc.toBlock
374 , Doc.tableCellJustify = Doc.JustifyStart
375 }
376 , Doc.tableCell
377 { Doc.tableCellContent = invItem & invoiceItemAction & Doc.toInline & Doc.toBlock
378 , Doc.tableCellJustify = Doc.JustifyStart
379 }
380 , Doc.tableCell
381 { Doc.tableCellContent =
382 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
383 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
384 ]
385 , Doc.tableCellJustify = Doc.JustifyStart
386 }
387 , Doc.tableCell
388 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
389 , Doc.tableCellJustify = Doc.JustifyEnd
390 }
391 , Doc.tableCell
392 { Doc.tableCellContent =
393 [ invItem & invoiceItemQuantity & Doc.toBlock
394 , let (qtyPercent, _actualRate) =
395 Accounting.fraction
396 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
397 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
398 in qtyPercent
399 & Accounting.quantityToRational
400 & (fromRational :: _ -> Double)
401 & (Printf.printf "(%02f%%)" :: _ -> String)
402 & Doc.toBlock
403 ]
404 , Doc.tableCellJustify = Doc.JustifyEnd
405 }
406 , Doc.tableCell
407 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
408 , Doc.tableCellJustify = Doc.JustifyEnd
409 }
410 ]
411 | (itemCount, invItem) <- invItemsChunk
412 ]
413 }
414 & Doc.toBlock
415 ]
416 ]
417 }
418 ]
419 }
420 & Doc.toBlock
421 & Doc.section
422 & ( \x ->
423 x{Doc.containerClasses = ["invoice-summary"]}
424 )
425 & Doc.toBlock
426 }
427 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
428 Doc.page
429 { Doc.pageSection = Just $ invTitle <> " — Details"
430 , Doc.pageContent =
431 Doc.flex
432 { Doc.flexDirection = Doc.FlexDirectionColumn
433 , Doc.flexGap = 0.5 & Doc.cm
434 , Doc.flexItems =
435 [ Doc.flexItem
436 { Doc.flexItemContent =
437 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
438 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
439 ]
440 & Doc.toBlock
441 , Doc.table
442 { Doc.tableTemplate =
443 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
444 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
445 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
446 , Doc.LengthRelative $ 1 & Doc.fr
447 , Doc.LengthRelative $ 1 & Doc.fr
448 , Doc.LengthAbsolute $ 5 & Doc.cm
449 , Doc.LengthRelative $ 2 & Doc.fr
450 ]
451 , Doc.tableRowsEvenOdd = True
452 , Doc.tableHeads =
453 Just
454 [ "#"
455 , "Date"
456 , "Duration"
457 , "Scope"
458 , "Action"
459 , "References"
460 , "Description"
461 ]
462 , Doc.tableRows =
463 [ [ Doc.tableCell
464 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
465 , Doc.tableCellJustify = Doc.JustifyEnd
466 }
467 , Doc.tableCell
468 { Doc.tableCellContent = work & workDate & Doc.toBlock
469 , Doc.tableCellJustify = Doc.JustifyCenter
470 }
471 , Doc.tableCell
472 { Doc.tableCellContent = work & workDuration & Doc.toBlock
473 , Doc.tableCellJustify = Doc.JustifyEnd
474 }
475 , Doc.tableCell
476 { Doc.tableCellContent = work & workScope & Doc.toInline & Doc.toBlock
477 , Doc.tableCellJustify = Doc.JustifyStart
478 }
479 , Doc.tableCell
480 { Doc.tableCellContent = work & workAction & Doc.toInline & Doc.toBlock
481 , Doc.tableCellJustify = Doc.JustifyStart
482 }
483 , Doc.tableCell
484 { Doc.tableCellContent =
485 [ ref & Doc.toInline
486 | ref <- work & workReferences
487 ]
488 & Doc.toInline
489 & Doc.toBlock
490 , Doc.tableCellJustify = Doc.JustifyStart
491 }
492 , Doc.tableCell
493 { Doc.tableCellContent = work & workDescription & Doc.toBlock
494 , Doc.tableCellJustify = Doc.JustifyStart
495 }
496 ]
497 | (workCount, work) <- worksChunk
498 ]
499 }
500 & Doc.toBlock
501 ]
502 }
503 ]
504 }
505 & Doc.toBlock
506 & Doc.section
507 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
508 & Doc.toBlock
509 }
510 ]
511 & mconcat
512 & ol1
513 & \l ->
514 l & List.map \(pageIdx, pag) ->
515 pag
516 { Doc.pageOrientation = Doc.PageOrientationPortrait
517 , Doc.pageSize = Doc.PageSizeA4
518 , Doc.pageNumber = Just pageIdx
519 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
520 , Doc.pageSide =
521 Just $
522 if pageIdx & even
523 then Doc.PageSideLeft
524 else Doc.PageSideRight
525 }
526 }
527
528 invoiceSummary ::
529 Ord scopeId =>
530 Show actionId =>
531 Ord actionId =>
532 Map actionId (Amount 100 unit) ->
533 [Work scopeId actionId] ->
534 Map (Ands scopeId) (Map actionId (InvoiceItem scopeId actionId _ _))
535 invoiceSummary invRates works =
536 Map.unionsWith
537 ( Map.unionWith
538 ( \x y ->
539 InvoiceItem
540 { invoiceItemScope = x & invoiceItemScope
541 , invoiceItemAction = x & invoiceItemAction
542 , invoiceItemPeriod =
543 Period
544 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
545 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
546 }
547 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
548 , invoiceItemType = x & invoiceItemType
549 , invoiceItemRate = x & invoiceItemRate
550 }
551 )
552 )
553 [ Map.singleton (work & workScope) $
554 Map.singleton (work & workAction) $
555 InvoiceItem
556 { invoiceItemScope = work & workScope
557 , invoiceItemAction = work & workAction
558 , invoiceItemType = InvoiceItemTypeService
559 , invoiceItemQuantity = work & workDuration
560 , invoiceItemPeriod =
561 Period
562 { periodBeginning = work & workDate
563 , periodEnd = work & workDate
564 }
565 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))
566 }
567 | work <- works
568 ]