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