@dloizides/invoice-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ Open a picked folder (`openDataFolder`), payment status in a separate `status.json`, sync-conflict and duplicate-number detection that blocks issuing, and `StorageExistsError` recognised by name so another package can implement the storage.
6
+
3
7
  ## 0.1.0
4
8
 
5
9
  First release: storage interface, numbering, validation, VAT rules, JSON layouts, pdfmake rendering, yearly schedules, credit notes, quarter CSV export, legacy folder migration.
package/README.md CHANGED
@@ -13,7 +13,7 @@ const { number, pdfPath } = await issueInvoice(storage, {
13
13
 
14
14
  ## Storage
15
15
 
16
- Implement `InvoiceStorage` (`src/storage.ts`) for your platform: `readText`, `readBytes`, `list`, `writeNew` (must refuse an existing file with `StorageExistsError`), `replace`, `remove`, `withLock`. Number claims run inside `withLock(NUMBER_LOCK, ...)`, so a browser implementation should back it with Web Locks.
16
+ `@dloizides/folder-store` implements this in the browser. Otherwise implement `InvoiceStorage` (`src/storage.ts`) for your platform: `readText`, `readBytes`, `list`, `writeNew` (must refuse an existing file with an error named `StorageExistsError`), `replace`, `remove`, `withLock`. Number claims run inside `withLock(NUMBER_LOCK, ...)`, so a browser implementation should back it with Web Locks.
17
17
 
18
18
  ## Folder format
19
19
 
@@ -26,6 +26,7 @@ layouts/<name>.json optional; overrides or adds to BUILT_I
26
26
  schedules/<clientId>-<year>.json 12 months, each a draft until issued
27
27
  invoices/<year>/<number>/invoice.json immutable record
28
28
  invoices/<year>/<number>/<number>.pdf the PDF; never replaced
29
+ invoices/<year>/<number>/status.json payment date; the only file here that may change
29
30
  ```
30
31
 
31
32
  `migrateLegacyFolder` moves `<year>/<number>.json` + `.pdf` into `invoices/`, never overwriting. Until then `nextNumber` counts both places.
@@ -41,3 +42,15 @@ A layout is plain data (`InvoiceLayout`): margins, sizes, three colours, title a
41
42
  ## Numbers
42
43
 
43
44
  Invoices number `2030-001`, credit notes `CN-2030-001`, each gap-free per year of their date. `issueDueMonths` dates each invoice on the day it issues; have your accountant confirm that suits your jurisdiction. Built-in VAT rules are not tax advice.
45
+
46
+ ## Opening a folder
47
+
48
+ `openDataFolder` returns `New` (empty; format written), `Existing`, `Legacy` (ours but no format; call `migrateLegacyFolder`) or `Foreign` (unrelated files; nothing written, ask the user).
49
+
50
+ ## Conflicts
51
+
52
+ `findFolderConflicts(storage, year)` reports sync-conflict copies, one number written twice and unreadable records, in both layouts. Issuing refuses while the year holds any and names each path; the user removes the copy. Operating system files are ignored.
53
+
54
+ ## Payment status
55
+
56
+ `markPaid`, `markUnpaid` and `readPaymentStatus` use `status.json` only. `paymentStateOf(record, status, today)` gives paid, unpaid or overdue; overdue starts the day after the due date, and no payment terms means never overdue.
package/dist/index.d.mts CHANGED
@@ -1,3 +1,9 @@
1
+ declare const enum FolderConflictKind {
2
+ SyncCopy = "sync copy",
3
+ DuplicateNumber = "duplicate number",
4
+ UnreadableRecord = "unreadable record"
5
+ }
6
+
1
7
  /**
2
8
  * The data folder as the core sees it. Paths are relative, with forward slashes.
3
9
  * Browser and Node implementations live outside this package.
@@ -22,6 +28,17 @@ interface InvoiceStorage {
22
28
  declare class StorageExistsError extends Error {
23
29
  constructor(path: string);
24
30
  }
31
+ /** Whether an error means the file exists; matched by name, so another package's class of this name counts. */
32
+ declare function isStorageExistsError(error: unknown): boolean;
33
+
34
+ interface FolderConflict {
35
+ kind: FolderConflictKind;
36
+ path: string;
37
+ }
38
+ /** Finds sync-conflict copies, duplicate numbers and unreadable records in one year, in both layouts. */
39
+ declare function findFolderConflicts(storage: InvoiceStorage, year: string): Promise<FolderConflict[]>;
40
+ /** Refuses to continue if the year holds a conflict, naming each path. */
41
+ declare function assertNoConflicts(storage: InvoiceStorage, year: string): Promise<void>;
25
42
 
26
43
  /** Whether an issued record is an invoice or a credit note. */
27
44
  declare const enum InvoiceKind {
@@ -232,6 +249,13 @@ interface MigrationResult {
232
249
  /** Moves <year>/<number>.json + .pdf to invoices/<year>/<number>/. Never overwrites. */
233
250
  declare function migrateLegacyFolder(storage: InvoiceStorage): Promise<MigrationResult>;
234
251
 
252
+ declare const enum FolderOpenState {
253
+ New = "new",
254
+ Existing = "existing",
255
+ Legacy = "legacy",
256
+ Foreign = "foreign"
257
+ }
258
+
235
259
  /** Where a layout places the document title. */
236
260
  declare const enum LayoutAlign {
237
261
  Left = "left",
@@ -308,11 +332,32 @@ declare function nextNumber(storage: InvoiceStorage, year: string, kind?: Invoic
308
332
  /** Writes the record, refusing if that number was already issued. Issued records are never replaced. */
309
333
  declare function claimInvoice(storage: InvoiceStorage, record: InvoiceRecord): Promise<string>;
310
334
 
335
+ /** Opens a picked folder: an empty one gets the format, anything else unrecognised is left untouched. */
336
+ declare function openDataFolder(storage: InvoiceStorage): Promise<FolderOpenState>;
337
+
311
338
  /** Reads the request's company, client and VAT rule, refusing if any is missing. */
312
339
  declare function loadParties(storage: InvoiceStorage, clientId: string): Promise<Parties>;
313
340
  /** Checks a request and returns its parties; final mode also refuses blank required fields. */
314
341
  declare function checkRequest(storage: InvoiceStorage, request: InvoiceRequest): Promise<Parties>;
315
342
 
343
+ declare const enum PaymentState {
344
+ Paid = "paid",
345
+ Unpaid = "unpaid",
346
+ Overdue = "overdue"
347
+ }
348
+
349
+ interface PaymentStatus {
350
+ paidOn: string | null;
351
+ }
352
+ /** Reads an invoice's payment status from its status.json; no file means unpaid. */
353
+ declare function readPaymentStatus(storage: InvoiceStorage, number: string): Promise<PaymentStatus>;
354
+ /** Records a payment date in status.json; the issued record is never touched. */
355
+ declare function markPaid(storage: InvoiceStorage, number: string, paidOn: string): Promise<void>;
356
+ /** Removes a recorded payment; the issued record is never touched. */
357
+ declare function markUnpaid(storage: InvoiceStorage, number: string): Promise<void>;
358
+ /** Says whether an invoice is paid, unpaid or overdue today; overdue starts the day after the due date. */
359
+ declare function paymentStateOf(record: InvoiceRecord, status: PaymentStatus, today: string): PaymentState;
360
+
316
361
  /** The logo a PDF shows: an image data URL or SVG text. */
317
362
  type PdfLogo = {
318
363
  image: string;
@@ -396,4 +441,4 @@ declare function validateRequest(req: InvoiceRequest): FieldError[];
396
441
  /** Lists required fields still blank; a final invoice is refused if any are. */
397
442
  declare function missingForFinal(company: Company, client: Client, lines?: InvoiceLine[]): FieldError[];
398
443
 
399
- export { BUILT_IN_LAYOUTS, BUILT_IN_RULES, type Client, type Company, type CreditNoteRequest, DEFAULT_LAYOUT, type FieldError, InvoiceError, InvoiceKind, type InvoiceLayout, type InvoiceLine, type InvoiceRecord, type InvoiceRequest, type InvoiceSchedule, type InvoiceStorage, type InvoiceView, type IssueDueOptions, type IssueDueResult, type IssueOptions, type IssuedInvoice, LayoutAlign, type MigrationResult, NUMBER_LOCK, type Parties, type PdfRender, type PrepareScheduleRequest, type RenderInput, type ResolvedLine, type ScheduleMonth, type ScheduleRef, StorageExistsError, type Totals, type VatRule, addDays, centsToAmount, checkRequest, claimInvoice, computeTotals, createMemoryStorage, displayDate, editScheduleMonth, ensureFormat, expandDescription, exportQuarterCsv, formatCents, invoiceDir, isBlank, isRealDate, issueCreditNote, issueDueMonths, issueInvoice, listIssued, loadLogo, loadParties, migrateLegacyFolder, missingForFinal, nextNumber, parseLayout, pdfPath, periodTexts, prepareYearSchedule, readInvoice, readSchedule, recordPath, renderDraftPdf, renderInvoicePdf, reprintInvoice, resolveLayout, resolveLines, selectLayoutName, toCents, validateLines, validateRequest, vatCents };
444
+ export { BUILT_IN_LAYOUTS, BUILT_IN_RULES, type Client, type Company, type CreditNoteRequest, DEFAULT_LAYOUT, type FieldError, type FolderConflict, FolderConflictKind, FolderOpenState, InvoiceError, InvoiceKind, type InvoiceLayout, type InvoiceLine, type InvoiceRecord, type InvoiceRequest, type InvoiceSchedule, type InvoiceStorage, type InvoiceView, type IssueDueOptions, type IssueDueResult, type IssueOptions, type IssuedInvoice, LayoutAlign, type MigrationResult, NUMBER_LOCK, type Parties, PaymentState, type PaymentStatus, type PdfRender, type PrepareScheduleRequest, type RenderInput, type ResolvedLine, type ScheduleMonth, type ScheduleRef, StorageExistsError, type Totals, type VatRule, addDays, assertNoConflicts, centsToAmount, checkRequest, claimInvoice, computeTotals, createMemoryStorage, displayDate, editScheduleMonth, ensureFormat, expandDescription, exportQuarterCsv, findFolderConflicts, formatCents, invoiceDir, isBlank, isRealDate, isStorageExistsError, issueCreditNote, issueDueMonths, issueInvoice, listIssued, loadLogo, loadParties, markPaid, markUnpaid, migrateLegacyFolder, missingForFinal, nextNumber, openDataFolder, parseLayout, paymentStateOf, pdfPath, periodTexts, prepareYearSchedule, readInvoice, readPaymentStatus, readSchedule, recordPath, renderDraftPdf, renderInvoicePdf, reprintInvoice, resolveLayout, resolveLines, selectLayoutName, toCents, validateLines, validateRequest, vatCents };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ declare const enum FolderConflictKind {
2
+ SyncCopy = "sync copy",
3
+ DuplicateNumber = "duplicate number",
4
+ UnreadableRecord = "unreadable record"
5
+ }
6
+
1
7
  /**
2
8
  * The data folder as the core sees it. Paths are relative, with forward slashes.
3
9
  * Browser and Node implementations live outside this package.
@@ -22,6 +28,17 @@ interface InvoiceStorage {
22
28
  declare class StorageExistsError extends Error {
23
29
  constructor(path: string);
24
30
  }
31
+ /** Whether an error means the file exists; matched by name, so another package's class of this name counts. */
32
+ declare function isStorageExistsError(error: unknown): boolean;
33
+
34
+ interface FolderConflict {
35
+ kind: FolderConflictKind;
36
+ path: string;
37
+ }
38
+ /** Finds sync-conflict copies, duplicate numbers and unreadable records in one year, in both layouts. */
39
+ declare function findFolderConflicts(storage: InvoiceStorage, year: string): Promise<FolderConflict[]>;
40
+ /** Refuses to continue if the year holds a conflict, naming each path. */
41
+ declare function assertNoConflicts(storage: InvoiceStorage, year: string): Promise<void>;
25
42
 
26
43
  /** Whether an issued record is an invoice or a credit note. */
27
44
  declare const enum InvoiceKind {
@@ -232,6 +249,13 @@ interface MigrationResult {
232
249
  /** Moves <year>/<number>.json + .pdf to invoices/<year>/<number>/. Never overwrites. */
233
250
  declare function migrateLegacyFolder(storage: InvoiceStorage): Promise<MigrationResult>;
234
251
 
252
+ declare const enum FolderOpenState {
253
+ New = "new",
254
+ Existing = "existing",
255
+ Legacy = "legacy",
256
+ Foreign = "foreign"
257
+ }
258
+
235
259
  /** Where a layout places the document title. */
236
260
  declare const enum LayoutAlign {
237
261
  Left = "left",
@@ -308,11 +332,32 @@ declare function nextNumber(storage: InvoiceStorage, year: string, kind?: Invoic
308
332
  /** Writes the record, refusing if that number was already issued. Issued records are never replaced. */
309
333
  declare function claimInvoice(storage: InvoiceStorage, record: InvoiceRecord): Promise<string>;
310
334
 
335
+ /** Opens a picked folder: an empty one gets the format, anything else unrecognised is left untouched. */
336
+ declare function openDataFolder(storage: InvoiceStorage): Promise<FolderOpenState>;
337
+
311
338
  /** Reads the request's company, client and VAT rule, refusing if any is missing. */
312
339
  declare function loadParties(storage: InvoiceStorage, clientId: string): Promise<Parties>;
313
340
  /** Checks a request and returns its parties; final mode also refuses blank required fields. */
314
341
  declare function checkRequest(storage: InvoiceStorage, request: InvoiceRequest): Promise<Parties>;
315
342
 
343
+ declare const enum PaymentState {
344
+ Paid = "paid",
345
+ Unpaid = "unpaid",
346
+ Overdue = "overdue"
347
+ }
348
+
349
+ interface PaymentStatus {
350
+ paidOn: string | null;
351
+ }
352
+ /** Reads an invoice's payment status from its status.json; no file means unpaid. */
353
+ declare function readPaymentStatus(storage: InvoiceStorage, number: string): Promise<PaymentStatus>;
354
+ /** Records a payment date in status.json; the issued record is never touched. */
355
+ declare function markPaid(storage: InvoiceStorage, number: string, paidOn: string): Promise<void>;
356
+ /** Removes a recorded payment; the issued record is never touched. */
357
+ declare function markUnpaid(storage: InvoiceStorage, number: string): Promise<void>;
358
+ /** Says whether an invoice is paid, unpaid or overdue today; overdue starts the day after the due date. */
359
+ declare function paymentStateOf(record: InvoiceRecord, status: PaymentStatus, today: string): PaymentState;
360
+
316
361
  /** The logo a PDF shows: an image data URL or SVG text. */
317
362
  type PdfLogo = {
318
363
  image: string;
@@ -396,4 +441,4 @@ declare function validateRequest(req: InvoiceRequest): FieldError[];
396
441
  /** Lists required fields still blank; a final invoice is refused if any are. */
397
442
  declare function missingForFinal(company: Company, client: Client, lines?: InvoiceLine[]): FieldError[];
398
443
 
399
- export { BUILT_IN_LAYOUTS, BUILT_IN_RULES, type Client, type Company, type CreditNoteRequest, DEFAULT_LAYOUT, type FieldError, InvoiceError, InvoiceKind, type InvoiceLayout, type InvoiceLine, type InvoiceRecord, type InvoiceRequest, type InvoiceSchedule, type InvoiceStorage, type InvoiceView, type IssueDueOptions, type IssueDueResult, type IssueOptions, type IssuedInvoice, LayoutAlign, type MigrationResult, NUMBER_LOCK, type Parties, type PdfRender, type PrepareScheduleRequest, type RenderInput, type ResolvedLine, type ScheduleMonth, type ScheduleRef, StorageExistsError, type Totals, type VatRule, addDays, centsToAmount, checkRequest, claimInvoice, computeTotals, createMemoryStorage, displayDate, editScheduleMonth, ensureFormat, expandDescription, exportQuarterCsv, formatCents, invoiceDir, isBlank, isRealDate, issueCreditNote, issueDueMonths, issueInvoice, listIssued, loadLogo, loadParties, migrateLegacyFolder, missingForFinal, nextNumber, parseLayout, pdfPath, periodTexts, prepareYearSchedule, readInvoice, readSchedule, recordPath, renderDraftPdf, renderInvoicePdf, reprintInvoice, resolveLayout, resolveLines, selectLayoutName, toCents, validateLines, validateRequest, vatCents };
444
+ export { BUILT_IN_LAYOUTS, BUILT_IN_RULES, type Client, type Company, type CreditNoteRequest, DEFAULT_LAYOUT, type FieldError, type FolderConflict, FolderConflictKind, FolderOpenState, InvoiceError, InvoiceKind, type InvoiceLayout, type InvoiceLine, type InvoiceRecord, type InvoiceRequest, type InvoiceSchedule, type InvoiceStorage, type InvoiceView, type IssueDueOptions, type IssueDueResult, type IssueOptions, type IssuedInvoice, LayoutAlign, type MigrationResult, NUMBER_LOCK, type Parties, PaymentState, type PaymentStatus, type PdfRender, type PrepareScheduleRequest, type RenderInput, type ResolvedLine, type ScheduleMonth, type ScheduleRef, StorageExistsError, type Totals, type VatRule, addDays, assertNoConflicts, centsToAmount, checkRequest, claimInvoice, computeTotals, createMemoryStorage, displayDate, editScheduleMonth, ensureFormat, expandDescription, exportQuarterCsv, findFolderConflicts, formatCents, invoiceDir, isBlank, isRealDate, isStorageExistsError, issueCreditNote, issueDueMonths, issueInvoice, listIssued, loadLogo, loadParties, markPaid, markUnpaid, migrateLegacyFolder, missingForFinal, nextNumber, openDataFolder, parseLayout, paymentStateOf, pdfPath, periodTexts, prepareYearSchedule, readInvoice, readPaymentStatus, readSchedule, recordPath, renderDraftPdf, renderInvoicePdf, reprintInvoice, resolveLayout, resolveLines, selectLayoutName, toCents, validateLines, validateRequest, vatCents };
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ var StorageExistsError = class extends Error {
7
7
  this.name = "StorageExistsError";
8
8
  }
9
9
  };
10
+ function isStorageExistsError(error) {
11
+ return typeof error === "object" && error !== null && error.name === "StorageExistsError";
12
+ }
10
13
 
11
14
  // src/types.ts
12
15
  var InvoiceError = class extends Error {
@@ -23,6 +26,11 @@ var FORMAT_VERSION = 1;
23
26
  var NUMBER_YEAR = /(\d{4})-\d+$/;
24
27
  var LEGACY_YEAR = /^\d{4}$/;
25
28
  var LEGACY_FILE = /^(\d{4}-\d+)\.(json|pdf)$/;
29
+ var OS_FILES = /* @__PURE__ */ new Set(["desktop.ini", "thumbs.db", ".ds_store"]);
30
+ var NUMBER_SHAPE = /^(CN-)?\d{4}-\d+$/;
31
+ function isOsFile(name) {
32
+ return OS_FILES.has(name.toLowerCase());
33
+ }
26
34
  function toJson(value2) {
27
35
  return JSON.stringify(value2, null, 2);
28
36
  }
@@ -58,7 +66,7 @@ async function ensureFormat(storage) {
58
66
  const text = await storage.readText(FORMAT_PATH);
59
67
  if (text === null) {
60
68
  await storage.writeNew(FORMAT_PATH, toJson({ version: FORMAT_VERSION })).catch((error) => {
61
- if (!(error instanceof StorageExistsError)) {
69
+ if (!isStorageExistsError(error)) {
62
70
  throw error;
63
71
  }
64
72
  });
@@ -101,6 +109,85 @@ async function migrateLegacyFolder(storage) {
101
109
  return result;
102
110
  }
103
111
 
112
+ // src/FolderConflictKind.ts
113
+ var FolderConflictKind = /* @__PURE__ */ ((FolderConflictKind2) => {
114
+ FolderConflictKind2["SyncCopy"] = "sync copy";
115
+ FolderConflictKind2["DuplicateNumber"] = "duplicate number";
116
+ FolderConflictKind2["UnreadableRecord"] = "unreadable record";
117
+ return FolderConflictKind2;
118
+ })(FolderConflictKind || {});
119
+
120
+ // src/conflicts.ts
121
+ var RECORD_FILE = "invoice.json";
122
+ var STATUS_FILE = "status.json";
123
+ var LEGACY_FILE2 = /^(\d{4}-\d+)\.(json|pdf)$/;
124
+ function seriesKey(number) {
125
+ const separator = number.lastIndexOf("-");
126
+ return `${number.slice(0, separator)}-${Number(number.slice(separator + 1))}`;
127
+ }
128
+ async function recordConflict(storage, dir, number) {
129
+ const path = `${dir}/${RECORD_FILE}`;
130
+ const text = await storage.readText(path);
131
+ if (text === null) {
132
+ return [];
133
+ }
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(text);
137
+ } catch {
138
+ return [{ kind: "unreadable record" /* UnreadableRecord */, path }];
139
+ }
140
+ if (typeof parsed !== "object" || parsed === null) {
141
+ return [{ kind: "unreadable record" /* UnreadableRecord */, path }];
142
+ }
143
+ return parsed.number === number ? [] : [{ kind: "duplicate number" /* DuplicateNumber */, path }];
144
+ }
145
+ async function numberFolderConflicts(storage, dir, number) {
146
+ const allowed = /* @__PURE__ */ new Set([RECORD_FILE, STATUS_FILE, `${number}.pdf`]);
147
+ const copies = (await storage.list(dir)).filter((name) => !allowed.has(name) && !isOsFile(name)).map((name) => ({ kind: "sync copy" /* SyncCopy */, path: `${dir}/${name}` }));
148
+ return [...copies, ...await recordConflict(storage, dir, number)];
149
+ }
150
+ async function currentLayoutConflicts(storage, year) {
151
+ const conflicts = [];
152
+ const numbers = /* @__PURE__ */ new Set();
153
+ const keys = /* @__PURE__ */ new Set();
154
+ const yearDir = `invoices/${year}`;
155
+ for (const name of (await storage.list(yearDir)).filter((entry) => !isOsFile(entry))) {
156
+ const path = `${yearDir}/${name}`;
157
+ if (!NUMBER_SHAPE.test(name) || numberYear(name) !== year) {
158
+ conflicts.push({ kind: "sync copy" /* SyncCopy */, path });
159
+ } else if (keys.has(seriesKey(name))) {
160
+ conflicts.push({ kind: "duplicate number" /* DuplicateNumber */, path });
161
+ } else {
162
+ keys.add(seriesKey(name));
163
+ numbers.add(name);
164
+ conflicts.push(...await numberFolderConflicts(storage, path, name));
165
+ }
166
+ }
167
+ return { conflicts, numbers };
168
+ }
169
+ async function legacyLayoutConflicts(storage, year, numbers) {
170
+ const names = (await storage.list(year)).filter((name) => !isOsFile(name));
171
+ return names.flatMap((name) => {
172
+ const number = LEGACY_FILE2.exec(name)?.[1];
173
+ const path = `${year}/${name}`;
174
+ if (number === void 0 || numberYear(number) !== year) {
175
+ return [{ kind: "sync copy" /* SyncCopy */, path }];
176
+ }
177
+ return numbers.has(number) ? [{ kind: "duplicate number" /* DuplicateNumber */, path }] : [];
178
+ });
179
+ }
180
+ async function findFolderConflicts(storage, year) {
181
+ const { conflicts, numbers } = await currentLayoutConflicts(storage, year);
182
+ return [...conflicts, ...await legacyLayoutConflicts(storage, year, numbers)];
183
+ }
184
+ async function assertNoConflicts(storage, year) {
185
+ const conflicts = await findFolderConflicts(storage, year);
186
+ if (conflicts.length > 0) {
187
+ throw new InvoiceError(conflicts.map(({ kind, path }) => ({ path: "folder", message: `${kind}: ${path}` })));
188
+ }
189
+ }
190
+
104
191
  // src/InvoiceKind.ts
105
192
  var InvoiceKind = /* @__PURE__ */ ((InvoiceKind2) => {
106
193
  InvoiceKind2["Invoice"] = "invoice";
@@ -270,7 +357,7 @@ async function claimInvoice(storage, record) {
270
357
  try {
271
358
  await storage.writeNew(path, toJson(record));
272
359
  } catch (error) {
273
- const message = error instanceof StorageExistsError ? `${record.number} is already issued` : `could not save ${record.number}: ${error.message}`;
360
+ const message = isStorageExistsError(error) ? `${record.number} is already issued` : `could not save ${record.number}: ${error.message}`;
274
361
  throw new InvoiceError([{ path: "number", message }]);
275
362
  }
276
363
  return path;
@@ -656,7 +743,6 @@ async function renderInvoicePdf(storage, input) {
656
743
  }
657
744
 
658
745
  // src/issue.ts
659
- var NUMBER_SHAPE = /^(CN-)?\d{4}-\d+$/;
660
746
  var NUMBER_LOCK = "invoice-number";
661
747
  function rendererFor(storage, options) {
662
748
  return options.render ?? ((input) => renderInvoicePdf(storage, input));
@@ -678,6 +764,7 @@ async function issueInvoice(storage, request, options = {}) {
678
764
  await ensureFormat(storage);
679
765
  const render = rendererFor(storage, options);
680
766
  return storage.withLock(NUMBER_LOCK, async () => {
767
+ await assertNoConflicts(storage, final.date.slice(0, 4));
681
768
  const number = await nextNumber(storage, final.date.slice(0, 4));
682
769
  const lines2 = resolveLines(final, parties.client);
683
770
  const pdf = await render({ request: final, number, parties, lines: lines2 });
@@ -764,6 +851,7 @@ async function issueCreditNote(storage, input, options = {}) {
764
851
  checkTarget(invoice, input);
765
852
  const render = rendererFor(storage, options);
766
853
  return storage.withLock(NUMBER_LOCK, async () => {
854
+ await assertNoConflicts(storage, input.date.slice(0, 4));
767
855
  const remaining = invoice.totals.netCents - await creditedSoFar(storage, invoice, input.date);
768
856
  const cents = creditCents(input, remaining);
769
857
  const number = await nextNumber(storage, input.date.slice(0, 4), "credit-note" /* CreditNote */);
@@ -828,6 +916,15 @@ async function exportQuarterCsv(storage, year, quarter) {
828
916
  return `${BOM}${[HEADER.join(","), ...records.map(toRow)].join(NEWLINE)}${NEWLINE}`;
829
917
  }
830
918
 
919
+ // src/FolderOpenState.ts
920
+ var FolderOpenState = /* @__PURE__ */ ((FolderOpenState2) => {
921
+ FolderOpenState2["New"] = "new";
922
+ FolderOpenState2["Existing"] = "existing";
923
+ FolderOpenState2["Legacy"] = "legacy";
924
+ FolderOpenState2["Foreign"] = "foreign";
925
+ return FolderOpenState2;
926
+ })(FolderOpenState || {});
927
+
831
928
  // src/memoryStorage.ts
832
929
  function toBytes2(data) {
833
930
  return typeof data === "string" ? new TextEncoder().encode(data) : data;
@@ -871,6 +968,69 @@ function createMemoryStorage(initial = {}) {
871
968
  };
872
969
  }
873
970
 
971
+ // src/openFolder.ts
972
+ var DATA_ENTRIES = /* @__PURE__ */ new Set(["company.json", "clients", "invoices", "rules", "layouts", "schedules"]);
973
+ var LEGACY_YEAR2 = /^\d{4}$/;
974
+ async function openDataFolder(storage) {
975
+ const names = (await storage.list("")).filter((name) => !isOsFile(name));
976
+ if (names.includes(FORMAT_PATH)) {
977
+ await ensureFormat(storage);
978
+ return "existing" /* Existing */;
979
+ }
980
+ if (names.length === 0) {
981
+ await ensureFormat(storage);
982
+ return "new" /* New */;
983
+ }
984
+ return names.some((name) => DATA_ENTRIES.has(name) || LEGACY_YEAR2.test(name)) ? "legacy" /* Legacy */ : "foreign" /* Foreign */;
985
+ }
986
+
987
+ // src/PaymentState.ts
988
+ var PaymentState = /* @__PURE__ */ ((PaymentState2) => {
989
+ PaymentState2["Paid"] = "paid";
990
+ PaymentState2["Unpaid"] = "unpaid";
991
+ PaymentState2["Overdue"] = "overdue";
992
+ return PaymentState2;
993
+ })(PaymentState || {});
994
+
995
+ // src/paymentStatus.ts
996
+ var STATUS_LOCK = "invoice-status";
997
+ function statusPath(number) {
998
+ return `${invoiceDir(number)}/status.json`;
999
+ }
1000
+ async function readPaymentStatus(storage, number) {
1001
+ const path = statusPath(number);
1002
+ if (await storage.readText(path) === null) {
1003
+ return { paidOn: null };
1004
+ }
1005
+ const { paidOn } = await readJsonFile(storage, path, "status");
1006
+ return { paidOn: typeof paidOn === "string" ? paidOn : null };
1007
+ }
1008
+ async function markPaid(storage, number, paidOn) {
1009
+ if (!isRealDate(paidOn)) {
1010
+ throw new InvoiceError([{ path: "paidOn", message: "must be a real date as YYYY-MM-DD" }]);
1011
+ }
1012
+ const record = await readInvoice(storage, number);
1013
+ if (record.kind !== "invoice" /* Invoice */) {
1014
+ throw new InvoiceError([{ path: "number", message: `${number} is a credit note; only an invoice can be paid` }]);
1015
+ }
1016
+ if (paidOn < record.request.date) {
1017
+ throw new InvoiceError([{ path: "paidOn", message: `must not be before the invoice date ${record.request.date}` }]);
1018
+ }
1019
+ const payment2 = { paidOn };
1020
+ await storage.withLock(STATUS_LOCK, () => storage.replace(statusPath(number), toJson(payment2)));
1021
+ }
1022
+ async function markUnpaid(storage, number) {
1023
+ await storage.withLock(STATUS_LOCK, () => storage.remove(statusPath(number)));
1024
+ }
1025
+ function paymentStateOf(record, status, today) {
1026
+ if (status.paidOn !== null) {
1027
+ return "paid" /* Paid */;
1028
+ }
1029
+ const terms = record.client.paymentTermsDays;
1030
+ const overdue = typeof terms === "number" && today > addDays(record.request.date, terms);
1031
+ return overdue ? "overdue" /* Overdue */ : "unpaid" /* Unpaid */;
1032
+ }
1033
+
874
1034
  // src/schedule.ts
875
1035
  var MONTHS_IN_YEAR = 12;
876
1036
  var YEAR3 = /^\d{4}$/;
@@ -901,7 +1061,7 @@ async function prepareYearSchedule(storage, request) {
901
1061
  try {
902
1062
  await storage.writeNew(schedulePath(schedule.id), toJson(schedule));
903
1063
  } catch (error) {
904
- if (error instanceof StorageExistsError) {
1064
+ if (isStorageExistsError(error)) {
905
1065
  throw new InvoiceError([{ path: "schedule", message: `${schedule.id} is already prepared` }]);
906
1066
  }
907
1067
  throw error;
@@ -982,12 +1142,16 @@ async function issueDueMonths(storage, id, options) {
982
1142
  exports.BUILT_IN_LAYOUTS = BUILT_IN_LAYOUTS;
983
1143
  exports.BUILT_IN_RULES = BUILT_IN_RULES;
984
1144
  exports.DEFAULT_LAYOUT = DEFAULT_LAYOUT;
1145
+ exports.FolderConflictKind = FolderConflictKind;
1146
+ exports.FolderOpenState = FolderOpenState;
985
1147
  exports.InvoiceError = InvoiceError;
986
1148
  exports.InvoiceKind = InvoiceKind;
987
1149
  exports.LayoutAlign = LayoutAlign;
988
1150
  exports.NUMBER_LOCK = NUMBER_LOCK;
1151
+ exports.PaymentState = PaymentState;
989
1152
  exports.StorageExistsError = StorageExistsError;
990
1153
  exports.addDays = addDays;
1154
+ exports.assertNoConflicts = assertNoConflicts;
991
1155
  exports.centsToAmount = centsToAmount;
992
1156
  exports.checkRequest = checkRequest;
993
1157
  exports.claimInvoice = claimInvoice;
@@ -998,24 +1162,31 @@ exports.editScheduleMonth = editScheduleMonth;
998
1162
  exports.ensureFormat = ensureFormat;
999
1163
  exports.expandDescription = expandDescription;
1000
1164
  exports.exportQuarterCsv = exportQuarterCsv;
1165
+ exports.findFolderConflicts = findFolderConflicts;
1001
1166
  exports.formatCents = formatCents;
1002
1167
  exports.invoiceDir = invoiceDir;
1003
1168
  exports.isBlank = isBlank;
1004
1169
  exports.isRealDate = isRealDate;
1170
+ exports.isStorageExistsError = isStorageExistsError;
1005
1171
  exports.issueCreditNote = issueCreditNote;
1006
1172
  exports.issueDueMonths = issueDueMonths;
1007
1173
  exports.issueInvoice = issueInvoice;
1008
1174
  exports.listIssued = listIssued;
1009
1175
  exports.loadLogo = loadLogo;
1010
1176
  exports.loadParties = loadParties;
1177
+ exports.markPaid = markPaid;
1178
+ exports.markUnpaid = markUnpaid;
1011
1179
  exports.migrateLegacyFolder = migrateLegacyFolder;
1012
1180
  exports.missingForFinal = missingForFinal;
1013
1181
  exports.nextNumber = nextNumber;
1182
+ exports.openDataFolder = openDataFolder;
1014
1183
  exports.parseLayout = parseLayout;
1184
+ exports.paymentStateOf = paymentStateOf;
1015
1185
  exports.pdfPath = pdfPath;
1016
1186
  exports.periodTexts = periodTexts;
1017
1187
  exports.prepareYearSchedule = prepareYearSchedule;
1018
1188
  exports.readInvoice = readInvoice;
1189
+ exports.readPaymentStatus = readPaymentStatus;
1019
1190
  exports.readSchedule = readSchedule;
1020
1191
  exports.recordPath = recordPath;
1021
1192
  exports.renderDraftPdf = renderDraftPdf;