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 scopeId actionId entId invId.
108 Get (Organization entId) entId =>
109 Get (Entity entId) entId =>
111 Doc.ToInline entId =>
112 Invoice scopeId 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
139 , Doc.ToInline scopeId
140 , Doc.ToInline actionId
143 HTMLIOable (Invoice scopeId actionId entId invId)
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 =
157 List.intersperse " - " $
161 & get @(Organization entId)
163 & get @(Entity entId)
166 Just n -> [Doc.toInline n]
167 , [inv & invoiceEmittedOn & Doc.toInline]
168 , ["Invoice #" <> (inv & invoiceIdInline)]
169 , inv & invoiceOrders
175 { documentTitle = invTitle
176 , documentAttachments =
178 [ "styles/Document.css"
179 , "styles/Invoice.css"
188 { Doc.pageSection = Just $ invTitle
191 { Doc.flexDirection = Doc.FlexDirectionColumn
192 , Doc.flexGap = 0.5 & Doc.cm
194 [ "Invoice" & Doc.classes ["title"]
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]
206 & (\x -> x{Doc.containerClasses = ["invoice-headers"]})
210 { orgEntity = toEnum 0 :: entId -- Explanation: ununused placeholder
212 [ "Seller" := inv & invoiceIssuer & get
213 , "Buyer" := inv & invoiceRecipient & get
219 x{Doc.containerClasses = ["invoice-from-to"]}
222 [ "Grand totals" & Doc.classes ["title"]
228 , "To pay (excl. taxes)"
233 { Doc.tableCellJustify = Doc.JustifyCenter
234 , Doc.tableCellContent =
236 <&> invoiceItemPeriod
240 { periodBeginning = min (x & periodBeginning) (y & periodBeginning)
241 , periodEnd = max (x & periodEnd) (y & periodEnd)
245 [ period & periodBeginning & Doc.toInline & Doc.BlockPara
246 , period & periodEnd & Doc.toInline & Doc.BlockPara
250 { Doc.tableCellJustify = Doc.JustifyCenter
251 , Doc.tableCellContent =
253 [ itm & invoiceItemQuantity
260 { Doc.tableCellJustify = Doc.JustifyCenter
261 , Doc.tableCellContent =
263 [ itm & invoiceItemTotal
276 [ "Mandatory legal notices" & Doc.classes ["title"]
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 ->
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 "€"))
290 InvoiceMentionIndemnitéTaux rate ->
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
297 , rate & Doc.toInline
298 , -- , " × montant impayé × nombre de jours de retard / 365.25"
299 " × unpaid amount × number of days late / 365.25"
302 | mention <- inv & invoiceMentions
308 <&> \blk -> Doc.flexItem{Doc.flexItemContent = blk}
313 , invSummary & ol1 & chunksOf 15 <&> \invItemsChunk ->
315 { Doc.pageSection = Just $ invTitle <> " — Summary"
318 { Doc.flexDirection = Doc.FlexDirectionColumn
319 , Doc.flexGap = 0.5 & Doc.cm
322 { Doc.flexItemContent =
323 [ [ ("Summary" :: Text) & Doc.toBlock & Doc.classes ["title"]
324 | invItemsChunk & headMaybe & maybe False (fst >>> (== 1))
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
339 , Doc.tableRowsEvenOdd = True
348 { Doc.tableCellContent =
349 [ "Rate" & Doc.BlockPara
350 , "(excl.\xA0taxes.)" & Doc.BlockPara
352 , Doc.tableCellJustify = Doc.JustifyCenter
356 { Doc.tableCellContent =
357 [ "Total" & Doc.BlockPara
358 , "(excl.\xA0taxes.)" & Doc.BlockPara
360 , Doc.tableCellJustify = Doc.JustifyCenter
365 { Doc.tableCellContent = itemCount & Printf.printf "%i" & Doc.toBlock @String
366 , Doc.tableCellJustify = Doc.JustifyEnd
369 { Doc.tableCellContent = invItem & invoiceItemType & Doc.toBlock
370 , Doc.tableCellJustify = Doc.JustifyCenter
373 { Doc.tableCellContent = invItem & invoiceItemScope & Doc.toInline & Doc.toBlock
374 , Doc.tableCellJustify = Doc.JustifyStart
377 { Doc.tableCellContent = invItem & invoiceItemAction & Doc.toInline & Doc.toBlock
378 , Doc.tableCellJustify = Doc.JustifyStart
381 { Doc.tableCellContent =
382 [ invItem & invoiceItemPeriod & periodBeginning & Doc.toInline & Doc.BlockPara
383 , invItem & invoiceItemPeriod & periodEnd & Doc.toInline & Doc.BlockPara
385 , Doc.tableCellJustify = Doc.JustifyStart
388 { Doc.tableCellContent = invItem & invoiceItemRate & Doc.toBlock
389 , Doc.tableCellJustify = Doc.JustifyEnd
392 { Doc.tableCellContent =
393 [ invItem & invoiceItemQuantity & Doc.toBlock
394 , let (qtyPercent, _actualRate) =
396 (invSummaryQuantityTotal & Accounting.quantityToRatio & (100 Prelude./))
397 (invItem & invoiceItemQuantity & Accounting.amountQuantity)
399 & Accounting.quantityToRational
400 & (fromRational :: _ -> Double)
401 & (Printf.printf "(%02f%%)" :: _ -> String)
404 , Doc.tableCellJustify = Doc.JustifyEnd
407 { Doc.tableCellContent = invItem & invoiceItemTotal & Doc.toBlock
408 , Doc.tableCellJustify = Doc.JustifyEnd
411 | (itemCount, invItem) <- invItemsChunk
423 x{Doc.containerClasses = ["invoice-summary"]}
427 , inv & invoiceWorks & ol1 & chunksOf 15 <&> \worksChunk ->
429 { Doc.pageSection = Just $ invTitle <> " — Details"
432 { Doc.flexDirection = Doc.FlexDirectionColumn
433 , Doc.flexGap = 0.5 & Doc.cm
436 { Doc.flexItemContent =
437 [ [ ("Details" :: Text) & Doc.toBlock & Doc.classes ["title"]
438 | worksChunk & headMaybe & maybe False (fst >>> (== 1))
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
451 , Doc.tableRowsEvenOdd = True
464 { Doc.tableCellContent = workCount & Printf.printf "%i" & Doc.toBlock @String
465 , Doc.tableCellJustify = Doc.JustifyEnd
468 { Doc.tableCellContent = work & workDate & Doc.toBlock
469 , Doc.tableCellJustify = Doc.JustifyCenter
472 { Doc.tableCellContent = work & workDuration & Doc.toBlock
473 , Doc.tableCellJustify = Doc.JustifyEnd
476 { Doc.tableCellContent = work & workScope & Doc.toInline & Doc.toBlock
477 , Doc.tableCellJustify = Doc.JustifyStart
480 { Doc.tableCellContent = work & workAction & Doc.toInline & Doc.toBlock
481 , Doc.tableCellJustify = Doc.JustifyStart
484 { Doc.tableCellContent =
486 | ref <- work & workReferences
490 , Doc.tableCellJustify = Doc.JustifyStart
493 { Doc.tableCellContent = work & workDescription & Doc.toBlock
494 , Doc.tableCellJustify = Doc.JustifyStart
497 | (workCount, work) <- worksChunk
507 & (\x -> x{Doc.containerClasses = ["invoice-details"]})
514 l & List.map \(pageIdx, pag) ->
516 { Doc.pageOrientation = Doc.PageOrientationPortrait
517 , Doc.pageSize = Doc.PageSizeA4
518 , Doc.pageNumber = Just pageIdx
519 , Doc.pageNumberTotal = Just $ l & List.length & fromIntegral
523 then Doc.PageSideLeft
524 else Doc.PageSideRight
532 Map actionId (Amount 100 unit) ->
533 [Work scopeId actionId] ->
534 Map (Ands scopeId) (Map actionId (InvoiceItem scopeId actionId _ _))
535 invoiceSummary invRates works =
540 { invoiceItemScope = x & invoiceItemScope
541 , invoiceItemAction = x & invoiceItemAction
542 , invoiceItemPeriod =
544 { periodBeginning = min (x & invoiceItemPeriod & periodBeginning) (y & invoiceItemPeriod & periodBeginning)
545 , periodEnd = max (x & invoiceItemPeriod & periodEnd) (y & invoiceItemPeriod & periodEnd)
547 , invoiceItemQuantity = (x & invoiceItemQuantity) + (y & invoiceItemQuantity)
548 , invoiceItemType = x & invoiceItemType
549 , invoiceItemRate = x & invoiceItemRate
553 [ Map.singleton (work & workScope) $
554 Map.singleton (work & workAction) $
556 { invoiceItemScope = work & workScope
557 , invoiceItemAction = work & workAction
558 , invoiceItemType = InvoiceItemTypeService
559 , invoiceItemQuantity = work & workDuration
560 , invoiceItemPeriod =
562 { periodBeginning = work & workDate
563 , periodEnd = work & workDate
565 , invoiceItemRate = invRates & Map.lookup (work & workAction) & fromMaybe (errorShow ("missing action" :: Text, work & workAction))