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
108 Get (Organization entId) entId =>
109 Get (Entity entId) entId =>
111 Doc.ToInline entId =>
112 Invoice 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 HTMLIOable (Invoice entId invId)
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 =
152 List.intersperse " - " $
156 & get @(Organization entId)
158 & get @(Entity entId)
161 Just n -> [Doc.toInline n]
162 , [inv & invoiceEmittedOn & Doc.toInline]
163 , ["Invoice #" <> (inv & invoiceIdInline)]
164 , inv & invoiceOrders
170 { documentTitle = invTitle
171 , documentAttachments =
173 [ "styles/Document.css"
174 , "styles/Invoice.css"
183 { Doc.pageSection = Just $ invTitle
186 { Doc.flexDirection = Doc.FlexDirectionColumn
187 , Doc.flexGap = 0.5 & Doc.cm
189 [ "Invoice" & Doc.classes ["title"]
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]
201 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
205 { orgEntity = toEnum 0 :: entId -- Explanation: ununused placeholder
207 [ "Seller" := inv & invoiceIssuer & get
208 , "Buyer" := inv & invoiceRecipient & get
214 x{Doc.containerClasses = ["invoice-from-to"]}
217 [ "Grand totals" & Doc.classes ["title"]
223 , "To pay (excl. taxes)"
228 { Doc.tableCellJustify = Doc.JustifyCenter
229 , Doc.tableCellContent =
231 <&> invoiceItemPeriod
235 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
236 , periodEnd = max (x & periodEnd) (y & periodEnd)
240 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
241 , period & periodEnd & Doc.toInline & Doc.BlockPara
245 { Doc.tableCellJustify = Doc.JustifyCenter
246 , Doc.tableCellContent =
248 [ itm & invoiceItemQuantity
255 { Doc.tableCellJustify = Doc.JustifyCenter
256 , Doc.tableCellContent =
258 [ itm & invoiceItemTotal
271 [ "Mandatory legal notices" & Doc.classes ["title"]
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 ->
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 "€"))
285 InvoiceMentionIndemnitéTaux rate ->
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
292 , rate & Doc.toInline
293 , -- , " × montant impayé × nombre de jours de retard / 365.25"
294 " × unpaid amount × number of days late / 365.25"
297 | mention <- inv & invoiceMentions
303 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
308 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
310 { Doc.pageSection = Just $ invTitle <> " — Summary"
313 { Doc.flexDirection = Doc.FlexDirectionColumn
314 , Doc.flexGap = 0.5 & Doc.cm
317 { Doc.flexItemContent =
318 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
319 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
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
334 , Doc.tableRowsEvenOdd = True
343 { Doc.tableCellContent =
344 [ "Rate" & Doc.BlockPara
345 , "(excl.\xA0taxes.)" & Doc.BlockPara
347 , Doc.tableCellJustify = Doc.JustifyCenter
351 { Doc.tableCellContent =
352 [ "Total" & Doc.BlockPara
353 , "(excl.\xA0taxes.)" & Doc.BlockPara
355 , Doc.tableCellJustify = Doc.JustifyCenter
360 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
361 , Doc.tableCellJustify = Doc.JustifyEnd
364 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
365 , Doc.tableCellJustify = Doc.JustifyCenter
368 { Doc.tableCellContent = invItem & invoiceItemScope & pathToBlock
369 , Doc.tableCellJustify = Doc.JustifyStart
372 { Doc.tableCellContent = invItem & invoiceItemAction & pathToBlock
373 , Doc.tableCellJustify = Doc.JustifyStart
376 { Doc.tableCellContent =
377 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
378 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
380 , Doc.tableCellJustify = Doc.JustifyStart
383 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
384 , Doc.tableCellJustify = Doc.JustifyEnd
387 { Doc.tableCellContent =
388 [ invItem & invoiceItemQuantity & Doc.toBlock
389 , let (qtyPercent, _actualRate) =
391 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
392 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
394 & Accounting.quantityToRational
395 & (fromRational :: _ -> Double)
396 & (Printf.printf "(%02f%%)" :: _ -> String)
399 , Doc.tableCellJustify = Doc.JustifyEnd
402 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
403 , Doc.tableCellJustify = Doc.JustifyEnd
406 | (itemCount, invItem) <- invItemsChunk
418 x{Doc.containerClasses = ["invoice-summary"]}
422 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
424 { Doc.pageSection = Just $ invTitle <> " — Details"
427 { Doc.flexDirection = Doc.FlexDirectionColumn
428 , Doc.flexGap = 0.5 & Doc.cm
431 { Doc.flexItemContent =
432 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
433 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
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
446 , Doc.tableRowsEvenOdd = True
459 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
460 , Doc.tableCellJustify = Doc.JustifyEnd
463 { Doc.tableCellContent = work & workDate & Doc.toBlock
464 , Doc.tableCellJustify = Doc.JustifyCenter
467 { Doc.tableCellContent =
468 work & workDuration & Doc.toBlock
469 , Doc.tableCellJustify = Doc.JustifyEnd
472 { Doc.tableCellContent = work & workScope & pathToBlock
473 , Doc.tableCellJustify = Doc.JustifyStart
476 { Doc.tableCellContent = work & workAction & pathToBlock
477 , Doc.tableCellJustify = Doc.JustifyStart
480 { Doc.tableCellContent =
482 | ref <- work & workReferences
486 , Doc.tableCellJustify = Doc.JustifyStart
489 { Doc.tableCellContent = work & workDescription & Doc.toBlock
490 , Doc.tableCellJustify = Doc.JustifyStart
493 | (workCount, work) <- worksChunk
503 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
510 l & List.map \(pageIdx, pag) ->
512 { Doc.pageOrientation = Doc.PageOrientationPortrait
513 , Doc.pageSize = Doc.PageSizeA4
514 , Doc.pageNumber = Just pageIdx
515 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
519 then Doc.PageSideLeft
520 else Doc.PageSideRight
528 & List.intersperse " / "
532 invoiceSummary :: _ -> _ -> Map [Text] (Map [Text] (InvoiceItem _ _))
533 invoiceSummary invRates works =
538 { invoiceItemScope = x & invoiceItemScope
539 , invoiceItemAction = x & invoiceItemAction
540 , invoiceItemPeriod =
542 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
543 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
545 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
546 , invoiceItemType = x & invoiceItemType
547 , invoiceItemRate = x & invoiceItemRate
551 [ Map.singleton (work & workScope) $
552 Map.singleton (work & workAction) $
554 { invoiceItemScope = work & workScope
555 , invoiceItemAction = work & workAction
556 , invoiceItemType = InvoiceItemTypeService
557 , invoiceItemQuantity = work & workDuration
558 , invoiceItemPeriod =
560 { periodBeginning = work & workDate
561 , periodEnd = work & workDate
563 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))