Add/stripe invoice link (#998)
* feat(supplier-invoices): show registered invoices under "Att betala" with inline approve Registered supplier invoices are already booked as debt (2440) but were hidden from the "Att betala" tab until approved, which confused users. The tab now shows registered invoices too, marked "Ej godkand" with a compact inline approve button. Approval remains the gate for payment, not visibility; status model and approve API untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reports): add date range filter to huvudbok (kontoanalys) Mounts the existing ReportDateRange control on /reports/huvudbok so the ledger can be narrowed to any date range within the fiscal year, matching Fortnox kontoanalys. Lines before the range roll into each account's opening balance so running balances stay correct at the range start; lines after the range are dropped. Applies to the XLSX export too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): add optional payment link on invoices (paste-link MVP) The user pastes a payment link created in their PSP dashboard (e.g. a Stripe Payment Link) onto an invoice. The recipient gets a "Betala online" button in the invoice email and a QR code + clickable link in the PDF payment box. No PSP integration server-side: this is the demand probe; a future Stripe Connect integration would auto-fill the same column. - invoices.payment_link_url (migration 20260709090000), https-only + 2048-char cap enforced in CreateInvoiceSchema; empty string normalises to undefined and build-invoice-write always writes a concrete value so clearing the field on a draft edit NULLs the column - editor field (real invoices only) with one-link-per-invoice hint; strings in sv+en (messages landed via e0e11066) - email button (customer.language, hidden for credit notes/proforma/ delivery notes, URL escaped for the href attribute) + URL in the plain-text part - PDF QR + link row following the Swish QR pattern; wired into send, download and preview routes - derived documents (credit note, proforma convert, recurring) do NOT copy the link: it encodes one amount for one specific invoice - MCP gnubok_create_invoice accepts payment_link_url (validated at staging and re-checked in the commit executor); v1 API exposes the column; tools/list token ceiling bumped 45K -> 45.5K (ledger entry in payload-size.bench.test.ts, headroom was <10 tokens) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): show oresavrundning on editor/form totals, supplier list and invoice email The rounding logic (getDisplayTotal) was correct but only applied on the PDF, invoice list/detail and review dialog. The invoice editor summary, the supplier invoice form totals and the supplier invoice list showed the raw ore total right next to the toggle, and the invoice email said "Att betala" with the unrounded invoice.total while the attached PDF showed the rounded amount (and the email also ignored the ROT/RUT deduction). Extract the PDF's Att betala block into getAmountToPay (lib/invoices/rounding.ts) and point PDF + email at it so they cannot drift; behavior-identical refactor for the PDF. Booked amounts stay ore-exact; display-only as designed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): adapt huvudbok date-range tests to the two-step entry-lines fetch The date-range tests (0969168f) mocked the old single-query shape with the parent entry embedded on each line; main's refactor (fetchEntryLines) queries journal_entries first and reattaches. Queue entry rows like the other tests so the merge of the two features is actually exercised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): fetch full invoice projection in v1 send so ROT/RUT deduction and payment link reach the PDF and email The v1 send route's hand-rolled column list omitted deduction_total, deduction_personnummer_last4, payment_link_url and the item-level ROT/RUT fields, so invoices sent via the public API overstated 'Att betala' and dropped the deduction box. Reuse the shared INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so the send row can never drift from the GET shape again. Also harden the supplier-invoice inline approve: a thrown fetch left the button stuck spinning; failures now refetch the true server state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0ef5593388
commit
7d7f604e00
@@ -291,6 +291,38 @@ describe('CreateInvoiceSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('payment_link_url accepts a valid https URL', () => {
|
||||
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
||||
payment_link_url: 'https://buy.stripe.com/test_abc123',
|
||||
}))
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.payment_link_url).toBe('https://buy.stripe.com/test_abc123')
|
||||
}
|
||||
})
|
||||
|
||||
it('payment_link_url normalises empty string to undefined (form always sends the field)', () => {
|
||||
const result = CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: '' }))
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.payment_link_url).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('payment_link_url rejects non-https and malformed values', () => {
|
||||
const bad = [
|
||||
'http://buy.stripe.com/abc', // plaintext link in a customer email
|
||||
'javascript:alert(1)',
|
||||
'not a url',
|
||||
`https://pay.example.se/${'a'.repeat(2049)}`, // over the 2048 cap
|
||||
]
|
||||
for (const value of bad) {
|
||||
expect(
|
||||
CreateInvoiceSchema.safeParse(validInvoice({ payment_link_url: value })).success,
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts invoice with per-line VAT rates', () => {
|
||||
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
||||
items: [
|
||||
|
||||
@@ -362,6 +362,28 @@ export const CreateInvoiceSchema = z.object({
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// Optional online payment link (manual MVP): the user pastes a link created
|
||||
// in their PSP dashboard (e.g. a Stripe Payment Link). https-only because the
|
||||
// URL is rendered in customer-facing emails/PDFs under the company's name.
|
||||
// The invoice form always sends the field ('' when empty), so empty string
|
||||
// normalises to undefined like external_invoice_number above; build-invoice-
|
||||
// write maps undefined to NULL so clearing the field on a draft edit works.
|
||||
payment_link_url: z
|
||||
.union([
|
||||
z
|
||||
.string()
|
||||
.max(2048)
|
||||
.refine((v) => {
|
||||
try {
|
||||
return new URL(v).protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Ogiltig betalningslänk (måste vara en https-adress)'),
|
||||
z.literal(''),
|
||||
])
|
||||
.transform((v) => v || undefined)
|
||||
.optional(),
|
||||
// ROT/RUT claim info. The personnummer is plaintext on the wire and gets
|
||||
// encrypted server-side before it ever hits the DB (see encryptPersonnummer
|
||||
// in lib/salary/personnummer.ts). `deduction_housing_designation` is the
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
export const INVOICE_FULL_COLUMNS =
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, notes, payment_link_url, reverse_charge_text, credited_invoice_id, document_type, converted_from_id, paid_at, paid_amount, remaining_amount, default_dimensions, deduction_total, deduction_personnummer_last4, created_at, updated_at'
|
||||
|
||||
export const INVOICE_ITEM_FULL_COLUMNS =
|
||||
'id, sort_order, line_type, description, quantity, unit, unit_price, line_total, vat_rate, vat_amount, article_id, revenue_account, deduction_type, deduction_amount, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, dimensions, created_at'
|
||||
|
||||
@@ -357,4 +357,96 @@ describe('invoice email templates', () => {
|
||||
.toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('payment link (payment_link_url)', () => {
|
||||
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
|
||||
const linkUrl = 'https://buy.stripe.com/test_abc123'
|
||||
|
||||
it('renders a pay-online button in HTML and the URL in plain text when set', () => {
|
||||
const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl })
|
||||
const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company })
|
||||
expect(html).toContain(`href="${linkUrl}"`)
|
||||
expect(html).toContain('Betala online')
|
||||
const text = generateInvoiceEmailText({ invoice: linked, customer: svCustomer, company })
|
||||
expect(text).toContain(`Betala online: ${linkUrl}`)
|
||||
})
|
||||
|
||||
it('uses the English label for English customers', () => {
|
||||
const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
|
||||
const linked = makeInvoice({ invoice_number: '1042', payment_link_url: linkUrl })
|
||||
const html = generateInvoiceEmailHtml({ invoice: linked, customer: enCustomer, company })
|
||||
expect(html).toContain('Pay online')
|
||||
expect(html).not.toContain('Betala online')
|
||||
})
|
||||
|
||||
it('omits the button when no link is set', () => {
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company })
|
||||
expect(html).not.toContain('Betala online')
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company })
|
||||
expect(text).not.toContain('Betala online')
|
||||
})
|
||||
|
||||
it('hides the button on credit notes even if a link is present on the row', () => {
|
||||
const creditNote = makeInvoice({
|
||||
invoice_number: '1043',
|
||||
credited_invoice_id: 'inv-orig',
|
||||
total: -5000,
|
||||
payment_link_url: linkUrl,
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice: creditNote, customer: svCustomer, company })
|
||||
expect(html).not.toContain('Betala online')
|
||||
})
|
||||
|
||||
it('escapes quote characters in the URL for the href attribute', () => {
|
||||
const sneaky = 'https://pay.example.se/x?a="onmouseover=alert(1)'
|
||||
const linked = makeInvoice({ invoice_number: '1042', payment_link_url: sneaky })
|
||||
const html = generateInvoiceEmailHtml({ invoice: linked, customer: svCustomer, company })
|
||||
expect(html).not.toContain('a="onmouseover')
|
||||
expect(html).toContain('"onmouseover=alert(1)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('öresavrundning: "Att betala" matches the PDF', () => {
|
||||
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
|
||||
|
||||
it('rounds the SEK total to whole kronor when rounding is on (company default)', () => {
|
||||
const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 })
|
||||
const html = generateInvoiceEmailHtml({ invoice: oreInvoice, customer: svCustomer, company })
|
||||
expect(html).toMatch(/1[\s ]235,00 SEK/)
|
||||
expect(html).not.toContain('234,56')
|
||||
const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company })
|
||||
expect(text).toMatch(/Att betala: 1[\s ]235,00 SEK/)
|
||||
})
|
||||
|
||||
it('keeps the exact öre when the per-invoice flag turns rounding off', () => {
|
||||
const exactInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, ore_rounding: false })
|
||||
const html = generateInvoiceEmailHtml({ invoice: exactInvoice, customer: svCustomer, company })
|
||||
expect(html).toMatch(/1[\s ]234,56 SEK/)
|
||||
expect(html).not.toMatch(/1[\s ]235,00 SEK/)
|
||||
})
|
||||
|
||||
it('does not round non-SEK invoices', () => {
|
||||
const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 })
|
||||
const text = generateInvoiceEmailText({ invoice: eurInvoice, customer: svCustomer, company })
|
||||
expect(text).toMatch(/1[\s ]234,56 EUR/)
|
||||
})
|
||||
|
||||
it('subtracts the ROT/RUT deduction so the email states what the customer owes', () => {
|
||||
const rotInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, deduction_total: 500 })
|
||||
const html = generateInvoiceEmailHtml({ invoice: rotInvoice, customer: svCustomer, company })
|
||||
expect(html).toContain('735,00 SEK')
|
||||
const text = generateInvoiceEmailText({ invoice: rotInvoice, customer: svCustomer, company })
|
||||
expect(text).toContain('Att betala: 735,00 SEK')
|
||||
})
|
||||
|
||||
it('uses the rounded amount for the {belopp} placeholder', () => {
|
||||
const withBelopp = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: 'Summa: {belopp}' } },
|
||||
})
|
||||
const oreInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56 })
|
||||
const text = generateInvoiceEmailText({ invoice: oreInvoice, customer: svCustomer, company: withBelopp })
|
||||
expect(text).toMatch(/Summa: 1[\s ]235,00 SEK/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
import { applyPlaceholders, sanitizeSubjectLine, userTextToHtml } from './user-text'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text'
|
||||
|
||||
type EmailLang = 'sv' | 'en'
|
||||
|
||||
@@ -23,6 +24,7 @@ const LABELS = {
|
||||
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
|
||||
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
|
||||
toPay: 'Att betala:',
|
||||
payOnline: 'Betala online',
|
||||
paymentHeading: 'Betalningsinformation',
|
||||
bank: 'Bank:',
|
||||
account: 'Kontonummer:',
|
||||
@@ -51,6 +53,7 @@ const LABELS = {
|
||||
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
|
||||
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
|
||||
toPay: 'Total due:',
|
||||
payOnline: 'Pay online',
|
||||
paymentHeading: 'Payment information',
|
||||
bank: 'Bank:',
|
||||
account: 'Account number:',
|
||||
@@ -150,7 +153,7 @@ function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record
|
||||
förnamn: fullName ? fullName.split(' ')[0] : '',
|
||||
företag: getCompanyPrimaryName(company),
|
||||
förfallodatum: formatDate(invoice.due_date),
|
||||
belopp: formatCurrencyForCustomer(invoice.total, invoice.currency, lang),
|
||||
belopp: formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +270,23 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<tr>
|
||||
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
|
||||
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : primaryColor};">
|
||||
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
|
||||
${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pay-online button: only when the user pasted a payment link on this
|
||||
invoice. The URL is schema-validated (https-only) but still escaped
|
||||
for the attribute context: a URL may legally contain quotes. -->
|
||||
${!hidePayment && invoice.payment_link_url ? `
|
||||
<div style="margin-bottom: 30px; text-align: center;">
|
||||
<a href="${escapeHtml(invoice.payment_link_url)}" style="display: inline-block; background: ${primaryColor}; color: #ffffff; text-decoration: none; padding: 12px 32px; border-radius: 6px; font-size: 16px; font-weight: 600;">
|
||||
${L.payOnline}
|
||||
</a>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Payment Details -->
|
||||
${!hidePayment ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
@@ -364,11 +378,12 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
|
||||
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
|
||||
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
|
||||
text += `${L.toPay} ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}\n`
|
||||
text += `${L.toPay} ${formatCurrencyForCustomer(getAmountToPay(invoice, company).toPay, invoice.currency, lang)}\n`
|
||||
text += `---\n\n`
|
||||
|
||||
if (!hidePayment) {
|
||||
text += `${L.paymentHeading}:\n`
|
||||
if (invoice.payment_link_url) text += `${L.payOnline}: ${invoice.payment_link_url}\n`
|
||||
if (company.bank_name) text += `${L.bank} ${company.bank_name}\n`
|
||||
if (company.clearing_number && company.account_number) {
|
||||
text += `${L.account} ${company.clearing_number}-${company.account_number}\n`
|
||||
|
||||
@@ -58,6 +58,34 @@ describe('buildInvoiceWriteData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps payment_link_url to a concrete trimmed value, null when absent', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
const customer = makeCustomer({ customer_type: 'swedish_business' })
|
||||
const withLink = await call(enqueue, supabase as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
payment_link_url: ' https://buy.stripe.com/test_abc123 ',
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
|
||||
})
|
||||
expect(withLink.ok).toBe(true)
|
||||
if (!withLink.ok) return
|
||||
expect(withLink.invoiceFields.payment_link_url).toBe('https://buy.stripe.com/test_abc123')
|
||||
|
||||
// Absent input must still produce an explicit null (not undefined):
|
||||
// supabase-js drops undefined keys, and a draft edit that cleared the
|
||||
// field relies on the NULL actually being written.
|
||||
const { supabase: supabase2, enqueue: enqueue2 } = createQueuedMockSupabase()
|
||||
enqueue2({ data: { vat_registered: true }, error: null })
|
||||
const withoutLink = await call(enqueue2, supabase2 as unknown as SupabaseClient, customer, {
|
||||
...baseHeader,
|
||||
items: [{ description: 'Konsult', quantity: 1, unit: 'tim', unit_price: 1000, vat_rate: 25 }],
|
||||
})
|
||||
expect(withoutLink.ok).toBe(true)
|
||||
if (!withoutLink.ok) return
|
||||
expect(withoutLink.invoiceFields.payment_link_url).toBeNull()
|
||||
})
|
||||
|
||||
it('handles a mixed-rate invoice (vat_rate becomes null on the header)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { vat_registered: true }, error: null })
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { makeCompanySettings } from '@/tests/helpers'
|
||||
import { prepareInvoicePdfRender, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { makeCompanySettings, makeInvoice } from '@/tests/helpers'
|
||||
|
||||
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
|
||||
|
||||
@@ -177,3 +177,29 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
|
||||
expect(branding.primaryColor).toBe('#c2410c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPaymentLinkQrDataUrl', () => {
|
||||
it('encodes the payment link as a PNG QR data URL for a real invoice', async () => {
|
||||
const invoice = makeInvoice({ payment_link_url: 'https://buy.stripe.com/test_abc123' })
|
||||
const qr = await buildPaymentLinkQrDataUrl(invoice)
|
||||
expect(qr).toMatch(new RegExp(`^${PNG_DATA_URL_PREFIX}`))
|
||||
})
|
||||
|
||||
it('returns null when the invoice has no payment link', async () => {
|
||||
expect(await buildPaymentLinkQrDataUrl(makeInvoice())).toBeNull()
|
||||
expect(await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: ' ' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-payable documents (proforma, delivery note, credit note)', async () => {
|
||||
const url = 'https://buy.stripe.com/test_abc123'
|
||||
expect(
|
||||
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'proforma' })),
|
||||
).toBeNull()
|
||||
expect(
|
||||
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, document_type: 'delivery_note' })),
|
||||
).toBeNull()
|
||||
expect(
|
||||
await buildPaymentLinkQrDataUrl(makeInvoice({ payment_link_url: url, credited_invoice_id: 'inv-orig' })),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { getAmountToPay, getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
|
||||
const inv = (total: number, currency: 'SEK' | 'EUR' = 'SEK') => ({ total, currency })
|
||||
const co = (ore_rounding: boolean) => ({ ore_rounding })
|
||||
@@ -69,3 +69,46 @@ describe('getDisplayTotal', () => {
|
||||
expect(r.displayed).toBe(99.99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAmountToPay', () => {
|
||||
it('equals the rounded display total when there is no deduction', () => {
|
||||
const r = getAmountToPay(inv(1234.56), co(true))
|
||||
expect(r.toPay).toBe(1235)
|
||||
expect(r.deductionApplies).toBe(false)
|
||||
expect(r.rounding.applies).toBe(true)
|
||||
expect(r.rounding.roundingDelta).toBe(0.44)
|
||||
})
|
||||
|
||||
it('subtracts the ROT/RUT deduction from the ROUNDED total', () => {
|
||||
const r = getAmountToPay({ ...inv(1234.56), deduction_total: 500 }, co(true))
|
||||
expect(r.deductionApplies).toBe(true)
|
||||
expect(r.toPay).toBe(735)
|
||||
})
|
||||
|
||||
it('subtracts the deduction from the raw total when rounding is off', () => {
|
||||
const r = getAmountToPay({ ...inv(1234.56), deduction_total: 500 }, co(false))
|
||||
expect(r.rounding.applies).toBe(false)
|
||||
expect(r.toPay).toBe(734.56)
|
||||
})
|
||||
|
||||
it('keeps öre precision in the deduction subtraction', () => {
|
||||
// 1235 - 166.67 must not pick up float noise.
|
||||
const r = getAmountToPay({ ...inv(1234.56), deduction_total: 166.67 }, co(true))
|
||||
expect(r.toPay).toBe(1068.33)
|
||||
})
|
||||
|
||||
it('ignores the deduction on credit notes (fakturamodellen does not apply)', () => {
|
||||
const r = getAmountToPay(
|
||||
{ ...inv(-1234.56), deduction_total: 500, credited_invoice_id: 'inv-1' },
|
||||
co(true),
|
||||
)
|
||||
expect(r.deductionApplies).toBe(false)
|
||||
expect(r.toPay).toBe(-1235)
|
||||
})
|
||||
|
||||
it('does not round non-SEK invoices but still applies the deduction', () => {
|
||||
const r = getAmountToPay({ ...inv(1234.56, 'EUR'), deduction_total: 100 }, co(true))
|
||||
expect(r.rounding.applies).toBe(false)
|
||||
expect(r.toPay).toBe(1134.56)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface InvoiceWriteInput {
|
||||
your_reference?: string
|
||||
our_reference?: string
|
||||
notes?: string
|
||||
/** Optional https payment link (schema-validated). Omitted/empty → null. */
|
||||
payment_link_url?: string
|
||||
/** Per-invoice öresavrundning override (display-only). Omitted → null (inherit company setting). */
|
||||
ore_rounding?: boolean
|
||||
deduction_personnummer?: string
|
||||
@@ -105,6 +107,7 @@ export type InvoiceWriteFields = {
|
||||
your_reference: string | null | undefined
|
||||
our_reference: string | null | undefined
|
||||
notes: string | null | undefined
|
||||
payment_link_url: string | null
|
||||
ore_rounding: boolean | null
|
||||
document_type: InvoiceDocumentType
|
||||
deduction_total: number
|
||||
@@ -365,6 +368,9 @@ export async function buildInvoiceWriteData(params: {
|
||||
your_reference: input.your_reference,
|
||||
our_reference: input.our_reference,
|
||||
notes: input.notes,
|
||||
// Always a concrete value (never undefined) so a draft edit that cleared
|
||||
// the field actually NULLs the column: supabase-js drops undefined keys.
|
||||
payment_link_url: input.payment_link_url?.trim() || null,
|
||||
// Display-only öresavrundning override; null inherits company_settings.ore_rounding.
|
||||
ore_rounding: input.ore_rounding ?? null,
|
||||
document_type: documentType,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoice.swish-qr')
|
||||
const paymentLinkLog = createLogger('invoice.payment-link-qr')
|
||||
|
||||
export interface InvoicePdfRenderExtras {
|
||||
branding: InvoiceBranding
|
||||
@@ -163,6 +164,29 @@ export async function prepareInvoicePdfRender(
|
||||
* `swishQrDataUrl` prop; the template gates rendering on the same payment box
|
||||
* that already shows the Swish number.
|
||||
*/
|
||||
/**
|
||||
* Build the payment-link QR for an invoice as a PNG data URL, or null when the
|
||||
* invoice carries no payment_link_url or it isn't a payable document (credit
|
||||
* notes, proformas and delivery notes show no payment box). The URL was
|
||||
* https-validated at write time (lib/api/schemas.ts); the QR simply encodes it
|
||||
* locally with the `qrcode` lib: no call to any payment provider.
|
||||
*/
|
||||
export async function buildPaymentLinkQrDataUrl(invoice: Invoice): Promise<string | null> {
|
||||
const url = invoice.payment_link_url?.trim()
|
||||
if (!url) return null
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
if (docType !== 'invoice' || invoice.credited_invoice_id) return null
|
||||
try {
|
||||
return await QRCode.toDataURL(url, { margin: 1, width: 240, errorCorrectionLevel: 'M' })
|
||||
} catch (err) {
|
||||
paymentLinkLog.warn('payment link QR generation failed', {
|
||||
invoiceId: invoice.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSwishQrDataUrl(
|
||||
company: CompanySettings,
|
||||
invoice: Invoice,
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
Text,
|
||||
View,
|
||||
Image,
|
||||
Link,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { generateOcrReference } from '@/lib/bankgiro/luhn'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
|
||||
type PdfLang = 'sv' | 'en'
|
||||
|
||||
@@ -86,6 +87,8 @@ const LABELS = {
|
||||
paymentReference: 'Betalningsreferens:',
|
||||
invoiceNumber: 'Fakturanummer:',
|
||||
swishQrCaption: 'Skanna för att betala med Swish',
|
||||
payOnline: 'Betala online:',
|
||||
paymentLinkQrCaption: 'Skanna för att betala online',
|
||||
// Footer
|
||||
orgNoLong: 'Org.nr:',
|
||||
vatRegNo: 'Momsreg.nr:',
|
||||
@@ -152,6 +155,8 @@ const LABELS = {
|
||||
paymentReference: 'Payment reference:',
|
||||
invoiceNumber: 'Invoice number:',
|
||||
swishQrCaption: 'Scan to pay with Swish',
|
||||
payOnline: 'Pay online:',
|
||||
paymentLinkQrCaption: 'Scan to pay online',
|
||||
orgNoLong: 'Reg. no.:',
|
||||
vatRegNo: 'VAT reg. no.:',
|
||||
// Statutory Swedish phrase: kept verbatim in both locales. Peppol SE-R-005
|
||||
@@ -636,9 +641,12 @@ interface InvoicePDFProps {
|
||||
/** Pre-rendered Swish payment QR (PNG data URL). Built offline in
|
||||
* pdf-render-helpers; null/omitted renders no QR. */
|
||||
swishQrDataUrl?: string | null
|
||||
/** Pre-rendered payment-link QR (PNG data URL) for invoice.payment_link_url.
|
||||
* Built offline in pdf-render-helpers; null/omitted renders no QR. */
|
||||
paymentLinkQrDataUrl?: string | null
|
||||
}
|
||||
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl }: InvoicePDFProps) {
|
||||
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding, swishQrDataUrl, paymentLinkQrDataUrl }: InvoicePDFProps) {
|
||||
const lang: PdfLang = language ?? customer.language ?? 'sv'
|
||||
const L = LABELS[lang]
|
||||
// Build the stylesheet per-render so each invoice picks up its company's
|
||||
@@ -915,14 +923,10 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
const rounding = getDisplayTotal(invoice, company)
|
||||
// ROT/RUT-avdrag reduces "Att betala": the customer only owes
|
||||
// (total - deduction); the rest is reclaimed from Skatteverket
|
||||
// via fakturamodellen. The rule does not apply to credit notes.
|
||||
const showDeduction = !isCreditNote && (invoice.deduction_total ?? 0) > 0
|
||||
const grandTotal = showDeduction
|
||||
? Math.round((rounding.displayed - (invoice.deduction_total ?? 0)) * 100) / 100
|
||||
: rounding.displayed
|
||||
// Shared with the invoice email (lib/email/invoice-templates.ts)
|
||||
// so the mail and the PDF always state the same "Att betala".
|
||||
const { rounding, deductionApplies: showDeduction, toPay: grandTotal } =
|
||||
getAmountToPay(invoice, company)
|
||||
return (
|
||||
<>
|
||||
{rounding.applies && (
|
||||
@@ -1033,6 +1037,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
{!isCreditNote && !isProforma && !isDeliveryNote && (
|
||||
<View style={styles.paymentSection}>
|
||||
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
|
||||
{invoice.payment_link_url && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.payOnline}</Text>
|
||||
<Link src={invoice.payment_link_url} style={styles.paymentValue}>
|
||||
{invoice.payment_link_url.length > 60
|
||||
? `${invoice.payment_link_url.slice(0, 57)}...`
|
||||
: invoice.payment_link_url}
|
||||
</Link>
|
||||
</View>
|
||||
)}
|
||||
{company.bank_name && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>{L.bank}</Text>
|
||||
@@ -1099,6 +1113,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.swishQrCaption}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Payment-link QR: shifts left when the Swish QR occupies the corner. */}
|
||||
{paymentLinkQrDataUrl && (
|
||||
<View style={{ position: 'absolute', top: 15, right: swishQrDataUrl ? 125 : 15, width: 96, alignItems: 'center' }}>
|
||||
<Image src={paymentLinkQrDataUrl} style={{ width: 96, height: 96 }} />
|
||||
<Text style={[styles.paymentLabel, { width: 'auto', marginTop: 2, textAlign: 'center' }]}>{L.paymentLinkQrCaption}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -55,3 +55,38 @@ export function getDisplayTotal(
|
||||
applies: true,
|
||||
}
|
||||
}
|
||||
|
||||
type AmountToPayShape = InvoiceTotalShape & {
|
||||
/** ROT/RUT deduction (fakturamodellen). Reduces what the customer owes. */
|
||||
deduction_total?: number | null
|
||||
/** Set on credit notes; the deduction rule does not apply to those. */
|
||||
credited_invoice_id?: string | null
|
||||
}
|
||||
|
||||
export interface AmountToPay {
|
||||
/** The öresavrundning outcome on the invoice total (before any deduction). */
|
||||
rounding: DisplayTotal
|
||||
/** True when a ROT/RUT deduction reduces the amount to pay. */
|
||||
deductionApplies: boolean
|
||||
/** Customer-facing "Att betala": rounded total minus any ROT/RUT deduction. */
|
||||
toPay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-facing "Att betala" for an invoice: öresavrundning via
|
||||
* getDisplayTotal, then the ROT/RUT deduction (the customer only owes
|
||||
* total - deduction; the rest is reclaimed from Skatteverket via
|
||||
* fakturamodellen). Extracted from the PDF totals block so the invoice email
|
||||
* shows the exact same amount as the attached PDF and the two cannot drift.
|
||||
*/
|
||||
export function getAmountToPay(
|
||||
invoice: AmountToPayShape,
|
||||
company: CompanyRoundingShape | null | undefined,
|
||||
): AmountToPay {
|
||||
const rounding = getDisplayTotal(invoice, company)
|
||||
const deductionApplies = !invoice.credited_invoice_id && (invoice.deduction_total ?? 0) > 0
|
||||
const toPay = deductionApplies
|
||||
? Math.round((rounding.displayed - (invoice.deduction_total ?? 0)) * 100) / 100
|
||||
: rounding.displayed
|
||||
return { rounding, deductionApplies, toPay }
|
||||
}
|
||||
|
||||
@@ -775,6 +775,19 @@ async function commitCreateInvoice(
|
||||
const uniqueRates = new Set(billableItems.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Validated https-only at staging time (gnubok_create_invoice); re-checked
|
||||
// here so a hand-crafted pending-operation row can't smuggle a non-https
|
||||
// link into customer-facing emails/PDFs. Invalid → dropped, never blocks.
|
||||
const paymentLinkUrl = (() => {
|
||||
const raw = typeof params.payment_link_url === 'string' ? params.payment_link_url.trim() : ''
|
||||
if (!raw || raw.length > 2048) return null
|
||||
try {
|
||||
return new URL(raw).protocol === 'https:' ? raw : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
@@ -800,6 +813,7 @@ async function commitCreateInvoice(
|
||||
our_reference: (params.our_reference as string) || null,
|
||||
your_reference: (params.your_reference as string) || null,
|
||||
notes: (params.notes as string) || null,
|
||||
payment_link_url: paymentLinkUrl,
|
||||
default_dimensions: defaultDimensions ?? {},
|
||||
})
|
||||
.select()
|
||||
|
||||
@@ -317,6 +317,98 @@ describe('generateGeneralLedger', () => {
|
||||
expect(acc.lines[2].description).toBe('Second')
|
||||
})
|
||||
|
||||
it('rolls lines before fromDate into the opening balance and drops lines after toDate', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
'rpc:compute_prior_opening_balances': [
|
||||
{
|
||||
data: [{ account_number: '1930', debit: 10000, credit: 0 }],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entries: [
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2026-03-15', voucher_number: 1, voucher_series: 'A', description: 'Pre-range', source_type: 'manual' },
|
||||
{ id: 'e2', entry_date: '2026-06-10', voucher_number: 2, voucher_series: 'A', description: 'In range', source_type: 'manual' },
|
||||
{ id: 'e3', entry_date: '2026-09-01', voucher_number: 3, voucher_series: 'A', description: 'Post-range', source_type: 'manual' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{
|
||||
data: [
|
||||
// Before the range: rolls into opening
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 2000, journal_entry_id: 'e1' },
|
||||
// Inside the range: shown
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' },
|
||||
// After the range: dropped entirely
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 300, journal_entry_id: 'e3' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
|
||||
],
|
||||
}
|
||||
|
||||
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', undefined, undefined, {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-06-30',
|
||||
})
|
||||
|
||||
const acc = report.accounts.find((a) => a.account_number === '1930')!
|
||||
// Opening at range start = period IB 10000 - pre-range 2000
|
||||
expect(acc.opening_balance).toBe(8000)
|
||||
expect(acc.lines).toHaveLength(1)
|
||||
expect(acc.lines[0].description).toBe('In range')
|
||||
expect(acc.lines[0].balance).toBe(8500)
|
||||
// Closing = opening + in-range movement only; post-range line excluded
|
||||
expect(acc.closing_balance).toBe(8500)
|
||||
expect(report.period).toEqual({ start: '2026-06-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('keeps an account visible when all its lines fall before the range', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { period_start: '2026-01-01', period_end: '2026-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entries: [
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2026-02-01', voucher_number: 1, voucher_series: 'A', description: 'Hyra feb', source_type: 'manual' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '5010', debit_amount: 4000, credit_amount: 0, journal_entry_id: 'e1' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{ data: [{ account_number: '5010', account_name: 'Lokalhyra' }], error: null },
|
||||
],
|
||||
}
|
||||
|
||||
const report = await generateGeneralLedger(supabase, 'company-1', 'period-1', undefined, undefined, {
|
||||
fromDate: '2026-06-01',
|
||||
toDate: '2026-06-30',
|
||||
})
|
||||
|
||||
const acc = report.accounts.find((a) => a.account_number === '5010')!
|
||||
expect(acc.opening_balance).toBe(4000)
|
||||
expect(acc.lines).toHaveLength(0)
|
||||
expect(acc.closing_balance).toBe(4000)
|
||||
})
|
||||
|
||||
it('uses Math.round for monetary precision', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
|
||||
@@ -246,7 +246,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
labelKey: 'name_huvudbok',
|
||||
descKey: 'desc_huvudbok',
|
||||
category: 'ledgers',
|
||||
params: 'fiscal',
|
||||
params: 'fiscal-range',
|
||||
exports: ['xlsx'],
|
||||
dimensions: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { getOpeningBalances } from './opening-balances'
|
||||
|
||||
@@ -59,6 +60,13 @@ export async function generateGeneralLedger(
|
||||
/** SIE dim → code filter ({"6":"P001"}). Opening balances are dropped
|
||||
* when set: they are company-wide and cannot be dimension-scoped. */
|
||||
dimensions?: Record<string, string>
|
||||
/** Inclusive date sub-range within the fiscal period (kontoanalys).
|
||||
* Lines before fromDate roll into each account's opening balance so
|
||||
* the running balance at the range start matches the full-year ledger;
|
||||
* lines after toDate are dropped. Callers validate the bounds
|
||||
* (parseReportDateRange). */
|
||||
fromDate?: string
|
||||
toDate?: string
|
||||
}
|
||||
): Promise<GeneralLedgerReport> {
|
||||
const dimensionFilter =
|
||||
@@ -135,7 +143,13 @@ export async function generateGeneralLedger(
|
||||
})
|
||||
|
||||
if (rawLines.length === 0 && openingBalances.size === 0) {
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
return {
|
||||
accounts: [],
|
||||
period: {
|
||||
start: options?.fromDate ?? period.period_start,
|
||||
end: options?.toDate ?? period.period_end,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch account names
|
||||
@@ -153,12 +167,25 @@ export async function generateGeneralLedger(
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
// Group lines by account
|
||||
// Group lines by account. Lines before fromDate accumulate per account so
|
||||
// they can roll into the opening balance below; lines after toDate drop.
|
||||
const fromDate = options?.fromDate
|
||||
const toDate = options?.toDate
|
||||
const accountLines = new Map<string, GeneralLedgerLine[]>()
|
||||
const preRangeMovements = new Map<string, number>()
|
||||
|
||||
for (const line of rawLines) {
|
||||
const entry = line.journal_entries
|
||||
const accNum = line.account_number
|
||||
const debit = Math.round((Number(line.debit_amount) || 0) * 100) / 100
|
||||
const credit = Math.round((Number(line.credit_amount) || 0) * 100) / 100
|
||||
|
||||
if (toDate && entry.entry_date > toDate) continue
|
||||
if (fromDate && entry.entry_date < fromDate) {
|
||||
preRangeMovements.set(accNum, (preRangeMovements.get(accNum) || 0) + debit - credit)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!accountLines.has(accNum)) {
|
||||
accountLines.set(accNum, [])
|
||||
}
|
||||
@@ -172,15 +199,24 @@ export async function generateGeneralLedger(
|
||||
journal_entry_id: line.journal_entry_id,
|
||||
description: entry.description || '',
|
||||
source_type: entry.source_type || '',
|
||||
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
|
||||
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
|
||||
debit,
|
||||
credit,
|
||||
balance: 0, // computed below
|
||||
...(hasDims ? { dimensions: line.dimensions as Record<string, string> } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
// Include accounts that have opening balance but no period lines
|
||||
for (const [accNum, balance] of openingBalances) {
|
||||
// Opening balance at the range start: period IB plus movements before
|
||||
// fromDate. Under a dimension filter the IB map is empty (company-wide IB
|
||||
// cannot be dimension-scoped) but pre-range movements are dimension-scoped
|
||||
// by the query, so they still roll in.
|
||||
const effectiveOpening = new Map<string, number>(openingBalances)
|
||||
for (const [accNum, movement] of preRangeMovements) {
|
||||
effectiveOpening.set(accNum, roundOre((effectiveOpening.get(accNum) || 0) + movement))
|
||||
}
|
||||
|
||||
// Include accounts that carry a balance into the range but have no lines in it
|
||||
for (const [accNum, balance] of effectiveOpening) {
|
||||
if (!accountLines.has(accNum) && Math.abs(balance) > 0.005) {
|
||||
accountLines.set(accNum, [])
|
||||
}
|
||||
@@ -201,7 +237,7 @@ export async function generateGeneralLedger(
|
||||
return a.voucher_number - b.voucher_number
|
||||
})
|
||||
|
||||
const opening = Math.round((openingBalances.get(accNum) || 0) * 100) / 100
|
||||
const opening = Math.round((effectiveOpening.get(accNum) || 0) * 100) / 100
|
||||
let runningBalance = opening
|
||||
|
||||
for (const line of accLines) {
|
||||
@@ -228,6 +264,9 @@ export async function generateGeneralLedger(
|
||||
|
||||
return {
|
||||
accounts: result,
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
period: {
|
||||
start: fromDate ?? period.period_start,
|
||||
end: toDate ?? period.period_end,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user