1 {-# LANGUAGE OverloadedLists #-}
2 {-# LANGUAGE PolyKinds #-}
4 {-# LANGUAGE UndecidableInstances #-}
5 {-# OPTIONS_GHC -Wno-orphans #-}
7 module Literate.Invoice.HTML where
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
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
30 class HTMLIOable a where
31 htmlIO :: a -> IO Html
33 instance Doc.ToBlock InvoiceType where
35 InvoiceTypeProForma -> "pro forma"
36 InvoiceTypeSale -> "sale"
37 InvoiceTypeVoucher -> "voucher"
38 instance Doc.ToBlock InvoiceItemType where
40 InvoiceItemTypeItem -> "item"
41 InvoiceItemTypeService -> "service"
43 instance (ToMarkup (Quantity qf), UnitShowS unit) => ToMarkup (Amount qf unit) where
45 (amountQuantity & HTML.toMarkup)
46 <> (if null unit then "" else "\x202F" <> (unit & toHtml))
49 instance (Doc.ToInline (Quantity qf), UnitShowS unit) => Doc.ToInline (Amount qf unit) where
51 (amountQuantity & Doc.toInline)
52 <> (if null unit then "" else "\x202F" <> (unit & Doc.toInline))
55 instance (Doc.ToBlock (Quantity qf), QuantFact qf, UnitShowS unit) => Doc.ToBlock (Amount qf unit) where
56 toBlock x = Doc.BlockPara $ x & Doc.toInline
58 instance QuantFact qf => ToMarkup (Quantity qf) where
63 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
64 & ( `Printf.formatArg`
67 , fmtAlternate = False
72 quantisationFactor @qf
73 & (Prelude.fromIntegral :: _ -> Double)
81 instance QuantFact qf => Doc.ToInline (Quantity qf) where
82 toInline qty = Doc.toInline do
85 & (Prelude.toRational >>> Prelude.fromRational :: Ratio Natural -> Double)
86 & ( `Printf.formatArg`
89 , fmtAlternate = False
94 quantisationFactor @qf
95 & (Prelude.fromIntegral :: _ -> Double)
103 instance QuantFact qf => Doc.ToBlock (Quantity qf) where
104 toBlock x = Doc.BlockPara $ x & Doc.toInline
107 forall actionId entId invId.
108 Get (Organization entId) entId =>
109 Get (Entity entId) entId =>
111 Doc.ToInline entId =>
112 Invoice actionId entId invId ->
114 invoiceIdInline inv =
118 & get @(Organization entId)
120 & get @(Entity entId)
123 , case inv & invoiceType of
124 InvoiceTypeProForma -> "pro-forma"
125 InvoiceTypeSale -> "sale"
126 InvoiceTypeVoucher -> "voucher"
127 , inv & invoiceId & fromEnum & show & Doc.toInline
132 ( Get (Organization entId) entId
133 , Get (Entity entId) entId
138 , Doc.ToInline actionId
141 HTMLIOable (Invoice actionId entId invId)
144 -- FixMe(portability): this absolute path is not portable out of my system
145 dataPath <- Self.getDataDir <&> File.normalise
146 -- paperCSS <- dataPath </> "styles" </> "Paper.css" & BS.readFile <&> Text.decodeUtf8
147 -- invoiceCSS <- dataPath </> "styles" </> "Invoice.css" & BS.readFile <&> Text.decodeUtf8
148 let invSummary :: [InvoiceItem _ _ _] =
149 invoiceSummary (inv & invoiceRates) (inv & invoiceWorks)
150 & foldMap (foldMap pure)
151 let invSummaryQuantityTotal :: Accounting.Quantity 100 =
152 invSummary <&> invoiceItemQuantity <&> Accounting.amountQuantity & Accounting.sumQuantities & fromMaybe 0
153 let invTitle :: Doc.Inline =
155 List.intersperse " - " $
159 & get @(Organization entId)
161 & get @(Entity entId)
164 Just n -> [Doc.toInline n]
165 , [inv & invoiceEmittedOn & Doc.toInline]
166 , ["Invoice #" <> (inv & invoiceIdInline)]
167 , inv & invoiceOrders
173 { documentTitle = invTitle
174 , documentAttachments =
176 [ "styles/Document.css"
177 , "styles/Invoice.css"
186 { Doc.pageSection = Just $ invTitle
189 { Doc.flexDirection = Doc.FlexDirectionColumn
190 , Doc.flexGap = 0.5 & Doc.cm
192 [ "Invoice" & Doc.classes ["title"]
194 ( [ ["InvoiceIdentifier" := inv & invoiceIdInline & Doc.toBlock]
195 , ["InvoiceType" := inv & invoiceType & Doc.toBlock]
196 , ["InvoiceOrders" := inv & invoiceOrders <&> (\x -> "-" := Doc.toBlock x) & Doc.List & Doc.toBlock]
197 , ["InvoiceEmittedOn" := inv & invoiceEmittedOn & Doc.toBlock]
198 , ["InvoicePaymentDueBefore" := inv & invoicePaymentDueBefore & Doc.toBlock]
204 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
208 { orgEntity = toEnum 0 :: entId -- Explanation: ununused placeholder
210 [ "Seller" := inv & invoiceIssuer & get
211 , "Buyer" := inv & invoiceRecipient & get
217 x{Doc.containerClasses = ["invoice-from-to"]}
220 [ "Grand totals" & Doc.classes ["title"]
226 , "To pay (excl. taxes)"
231 { Doc.tableCellJustify = Doc.JustifyCenter
232 , Doc.tableCellContent =
234 <&> invoiceItemPeriod
238 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
239 , periodEnd = max (x & periodEnd) (y & periodEnd)
243 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
244 , period & periodEnd & Doc.toInline & Doc.BlockPara
248 { Doc.tableCellJustify = Doc.JustifyCenter
249 , Doc.tableCellContent =
251 [ itm & invoiceItemQuantity
258 { Doc.tableCellJustify = Doc.JustifyCenter
259 , Doc.tableCellContent =
261 [ itm & invoiceItemTotal
274 [ "Mandatory legal notices" & Doc.classes ["title"]
277 InvoiceMentionTVANonApplicable ->
278 -- "TVA non applicable, art. 293 B du code général des impôts."
279 "—" := "VAT not applicable, art. 293 B of the French General Tax Code."
280 InvoiceMentionIndemnitéForfaitaire ->
282 [ -- "Indemnité forfaitaire pour frais de recouvrement en cas de retard de paiement\x202F: "
283 "Fixed compensation for recovery costs in case of late payment: "
284 , (40 :: Accounting.Amount 100 (Accounting.UnitName "€"))
288 InvoiceMentionIndemnitéTaux rate ->
290 [ -- "Taux des pénalités pour retard de paiement exigibles à compter du "
291 "Late payment penalty rate (applicable from "
292 , inv & invoicePaymentDueBefore & Time.addLocalTime Time.nominalDay & Doc.toInline
295 , rate & Doc.toInline
296 , -- , " × montant impayé × nombre de jours de retard / 365.25"
297 " × unpaid amount × number of days late / 365.25"
300 | mention <- inv & invoiceMentions
306 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
311 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
313 { Doc.pageSection = Just $ invTitle <> " — Summary"
316 { Doc.flexDirection = Doc.FlexDirectionColumn
317 , Doc.flexGap = 0.5 & Doc.cm
320 { Doc.flexItemContent =
321 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
322 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
327 { Doc.tableTemplate =
328 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
329 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
330 , Doc.LengthRelative $ 1 & Doc.fr
331 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
332 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
333 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
334 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
335 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
337 , Doc.tableRowsEvenOdd = True
346 { Doc.tableCellContent =
347 [ "Rate" & Doc.BlockPara
348 , "(excl.\xA0taxes.)" & Doc.BlockPara
350 , Doc.tableCellJustify = Doc.JustifyCenter
354 { Doc.tableCellContent =
355 [ "Total" & Doc.BlockPara
356 , "(excl.\xA0taxes.)" & Doc.BlockPara
358 , Doc.tableCellJustify = Doc.JustifyCenter
363 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
364 , Doc.tableCellJustify = Doc.JustifyEnd
367 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
368 , Doc.tableCellJustify = Doc.JustifyCenter
371 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
372 , Doc.tableCellJustify = Doc.JustifyStart
375 { Doc.tableCellContent = invItem & invoiceItemAction & Doc.toInline & Doc.toBlock
376 , Doc.tableCellJustify = Doc.JustifyStart
379 { Doc.tableCellContent =
380 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
381 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
383 , Doc.tableCellJustify = Doc.JustifyStart
386 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
387 , Doc.tableCellJustify = Doc.JustifyEnd
390 { Doc.tableCellContent =
391 [ invItem & invoiceItemQuantity & Doc.toBlock
392 , let (qtyPercent, _actualRate) =
394 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
395 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
397 & Accounting.quantityToRational
398 & (fromRational :: _ -> Double)
399 & (Printf.printf "(%02f%%)" :: _ -> String)
402 , Doc.tableCellJustify = Doc.JustifyEnd
405 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
406 , Doc.tableCellJustify = Doc.JustifyEnd
409 | (itemCount, invItem) <- invItemsChunk
421 x{Doc.containerClasses = ["invoice-summary"]}
425 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
427 { Doc.pageSection = Just $ invTitle <> " — Details"
430 { Doc.flexDirection = Doc.FlexDirectionColumn
431 , Doc.flexGap = 0.5 & Doc.cm
434 { Doc.flexItemContent =
435 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
436 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
440 { Doc.tableTemplate =
441 [ Doc.LengthRelative $ Doc.LengthRelativeMaxContent
442 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
443 , Doc.LengthRelative $ Doc.LengthRelativeMaxContent
444 , Doc.LengthRelative $ 1 & Doc.fr
445 , Doc.LengthRelative $ 1 & Doc.fr
446 , Doc.LengthAbsolute $ 5 & Doc.cm
447 , Doc.LengthRelative $ 2 & Doc.fr
449 , Doc.tableRowsEvenOdd = True
462 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
463 , Doc.tableCellJustify = Doc.JustifyEnd
466 { Doc.tableCellContent = work & workDate & Doc.toBlock
467 , Doc.tableCellJustify = Doc.JustifyCenter
470 { Doc.tableCellContent =
471 work & workDuration & Doc.toBlock
472 , Doc.tableCellJustify = Doc.JustifyEnd
475 { Doc.tableCellContent = work & workScope & pathToBlock
476 , Doc.tableCellJustify = Doc.JustifyStart
479 { Doc.tableCellContent = work & workAction & Doc.toInline & Doc.toBlock
480 , Doc.tableCellJustify = Doc.JustifyStart
483 { Doc.tableCellContent =
485 | ref <- work & workReferences
489 , Doc.tableCellJustify = Doc.JustifyStart
492 { Doc.tableCellContent = work & workDescription & Doc.toBlock
493 , Doc.tableCellJustify = Doc.JustifyStart
496 | (workCount, work) <- worksChunk
506 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
513 l & List.map \(pageIdx, pag) ->
515 { Doc.pageOrientation = Doc.PageOrientationPortrait
516 , Doc.pageSize = Doc.PageSizeA4
517 , Doc.pageNumber = Just pageIdx
518 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
522 then Doc.PageSideLeft
523 else Doc.PageSideRight
531 & List.intersperse " / "
538 _ -> [Work actionId] -> Map [Text] (Map actionId (InvoiceItem actionId _ _))
539 invoiceSummary invRates works =
544 { invoiceItemScope = x & invoiceItemScope
545 , invoiceItemAction = x & invoiceItemAction
546 , invoiceItemPeriod =
548 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
549 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
551 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
552 , invoiceItemType = x & invoiceItemType
553 , invoiceItemRate = x & invoiceItemRate
557 [ Map.singleton (work & workScope) $
558 Map.singleton (work & workAction) $
560 { invoiceItemScope = work & workScope
561 , invoiceItemAction = work & workAction
562 , invoiceItemType = InvoiceItemTypeService
563 , invoiceItemQuantity = work & workDuration
564 , invoiceItemPeriod =
566 { periodBeginning = work & workDate
567 , periodEnd = work & workDate
569 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))