| 1 | /** |
| 2 | * Conversion helpers shared by the two paths that turn billable time entries |
| 3 | * into invoice line items: the manual `ImportTimeEntriesDialog` and the silent |
| 4 | * `autoImportTime` effect in the invoice editor. |
| 5 | * |
| 6 | * The chosen unit only changes how a line *reads* — `amount` is always derived |
| 7 | * from the exact duration, so invoice totals stay identical whether entries are |
| 8 | * imported in hours or minutes. See docs/adr/0002-invoice-import-time-unit-amount-accuracy.md. |
| 9 | */ |
| 10 | |
| 11 | export type InvoiceTimeUnit |
| 12 | |
| 13 | export interface TimeEntryLineAmountsInput |
| 14 | durationMinutes: number |
| 15 | /** Member's hourly rate, in integer cents per hour. */ |
| 16 | hourlyRateCents: number |
| 17 | unit: InvoiceTimeUnit |
| 18 | } |
| 19 | |
| 20 | export interface TimeEntryLineAmounts |
| 21 | amount: string |
| 22 | quantity: string |
| 23 | unitPrice: string |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * Computes the `{ quantity, unitPrice, amount }` strings for a line item. |
| 28 | * |
| 29 | * - `quantity` reflects the unit: hours → `1.50`, minutes → `90`. |
| 30 | * - `unitPrice` is the per-unit rate: hours → rate/100 (2 dp), minutes → |
| 31 | * rate/100/60 (4 dp, the lossy per-minute rate). |
| 32 | * - `amount` is always derived from the exact duration (rate/100 × hours, |
| 33 | * 2 dp), independent of the rounded `unitPrice`, so totals never drift. |
| 34 | */ |
| 35 | export function timeEntryLineAmounts({ |
| 36 | durationMinutes, |
| 37 | hourlyRateCents, |
| 38 | unit, |
| 39 | }: TimeEntryLineAmountsInput) |
| 40 | const hours = |
| 41 | const ratePerHour = |
| 42 | const totalCents = |
| 43 | const amount = |
| 44 | |
| 45 | if (unit === |
| 46 | return { |
| 47 | quantity: String(durationMinutes |
| 48 | unitPrice: (ratePerHour / |
| 49 | amount, |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | return { |
| 54 | quantity: hours.toFixed |
| 55 | unitPrice: ratePerHour.toFixed |
| 56 | amount, |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Human-facing duration label in the chosen unit, e.g. `"1.5h"` or `"90m"`. |
| 62 | * Used for line-item description suffixes and the dialog's "selected" badge. |
| 63 | */ |
| 64 | export function formatDurationInUnit( |
| 65 | durationMinutes: number, |
| 66 | unit: InvoiceTimeUnit |
| 67 | ): string { |
| 68 | if (unit === |
| 69 | return `${durationMinutes} |
| 70 | } |
| 71 | return `${Number(( |
| 72 | } |