@zenuinebit/invoice-number 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +26 -2
- package/dist/index.cjs +103 -0
- package/dist/index.d.cts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +75 -0
- package/package.json +26 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZenuineBit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zenuinebit/invoice-number
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sequential invoice numbers that restart each financial year, plus a check for the Indian GST format rule. No dependencies. Dates are `"YYYY-MM-DD"` strings.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { next, checkGstFormat, financialYear } from "@zenuinebit/invoice-number";
|
|
7
|
+
|
|
8
|
+
const format = { pattern: "INV/{FY}/{SEQ:4}" }; // reset defaults to "financial-year"
|
|
9
|
+
|
|
10
|
+
// First invoice: no counter yet
|
|
11
|
+
let r = next(null, "2025-04-01", format); // { number: "INV/2025-26/0001", counter: { period: "2025-26", seq: 1 } }
|
|
12
|
+
r = next(r.counter, "2025-09-10", format); // "INV/2025-26/0002"
|
|
13
|
+
r = next(r.counter, "2026-04-01", format); // "INV/2026-27/0001" - new financial year
|
|
14
|
+
|
|
15
|
+
checkGstFormat("INV/2025-26/0042"); // { valid: true, problems: [] }
|
|
16
|
+
financialYear("2025-03-31"); // "2024-25"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Pattern tokens:** `{FY}` 2025-26, `{FY2}` 2526, `{YYYY}`, `{YY}`, `{MM}`, `{SEQ:n}` (sequence padded to n digits) and `{SEQ}`.
|
|
20
|
+
**Reset:** `"financial-year"` (default), `"calendar-year"`, `"month"`, `"never"`. `fyStartMonth` defaults to 4 (April).
|
|
21
|
+
|
|
22
|
+
**Use it safely**
|
|
23
|
+
- The package keeps no state. Store the counter in your database and call `next` inside the same transaction (or with a row lock) that saves the invoice. Two requests reading the same counter would otherwise issue the same number.
|
|
24
|
+
- A back-dated invoice is fine inside the current period but refused if it falls in an earlier one, so it cannot reuse a number.
|
|
25
|
+
- `checkGstFormat` tests Rule 46: at most 16 characters, only letters, digits, `/` and `-`. `INV/2025-26/00042` is 17 characters and fails, so size your pattern for it.
|
|
26
|
+
|
|
27
|
+
Not included: credit-note or debit-note series, multiple series per branch (use one counter per series), and gap detection.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
checkGstFormat: () => checkGstFormat,
|
|
24
|
+
financialYear: () => financialYear,
|
|
25
|
+
format: () => format,
|
|
26
|
+
next: () => next
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
function parseDate(date) {
|
|
30
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
31
|
+
if (!m) throw new Error(`Invalid date "${date}", expected YYYY-MM-DD`);
|
|
32
|
+
const [y, mo, d] = [+m[1], +m[2], +m[3]];
|
|
33
|
+
if (new Date(Date.UTC(y, mo - 1, d)).toISOString().slice(0, 10) !== date) throw new Error(`Invalid date "${date}"`);
|
|
34
|
+
return { y, m: mo };
|
|
35
|
+
}
|
|
36
|
+
function financialYear(date, fyStartMonth = 4) {
|
|
37
|
+
if (!Number.isInteger(fyStartMonth) || fyStartMonth < 1 || fyStartMonth > 12) throw new Error("fyStartMonth must be between 1 and 12");
|
|
38
|
+
const { y, m } = parseDate(date);
|
|
39
|
+
if (fyStartMonth === 1) return String(y);
|
|
40
|
+
const start = m >= fyStartMonth ? y : y - 1;
|
|
41
|
+
return `${start}-${String((start + 1) % 100).padStart(2, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
function periodKey(date, f) {
|
|
44
|
+
const { y, m } = parseDate(date);
|
|
45
|
+
switch (f.reset ?? "financial-year") {
|
|
46
|
+
case "financial-year":
|
|
47
|
+
return financialYear(date, f.fyStartMonth ?? 4);
|
|
48
|
+
case "calendar-year":
|
|
49
|
+
return String(y);
|
|
50
|
+
case "month":
|
|
51
|
+
return `${y}-${String(m).padStart(2, "0")}`;
|
|
52
|
+
case "never":
|
|
53
|
+
return "all";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
var TOKEN = /\{(FY2|FY|YYYY|YY|MM|SEQ)(?::(\d{1,2}))?\}/g;
|
|
57
|
+
function format(seq, date, f) {
|
|
58
|
+
if (!Number.isInteger(seq) || seq < 1) throw new Error("seq must be a whole number of at least 1");
|
|
59
|
+
const { y, m } = parseDate(date);
|
|
60
|
+
const fy = financialYear(date, f.fyStartMonth ?? 4);
|
|
61
|
+
return f.pattern.replace(TOKEN, (_, token, width) => {
|
|
62
|
+
switch (token) {
|
|
63
|
+
case "FY":
|
|
64
|
+
return fy;
|
|
65
|
+
case "FY2":
|
|
66
|
+
return fy.includes("-") ? fy.slice(2, 4) + fy.slice(5) : fy.slice(2);
|
|
67
|
+
case "YYYY":
|
|
68
|
+
return String(y);
|
|
69
|
+
case "YY":
|
|
70
|
+
return String(y % 100).padStart(2, "0");
|
|
71
|
+
case "MM":
|
|
72
|
+
return String(m).padStart(2, "0");
|
|
73
|
+
default:
|
|
74
|
+
return String(seq).padStart(width ? Number(width) : 0, "0");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function next(counter, date, f) {
|
|
79
|
+
if (!/\{SEQ(?::\d{1,2})?\}/.test(f.pattern)) throw new Error("pattern must contain {SEQ} or {SEQ:n}");
|
|
80
|
+
const period = periodKey(date, f);
|
|
81
|
+
let seq;
|
|
82
|
+
if (!counter || counter.period !== period) {
|
|
83
|
+
if (counter && (f.reset ?? "financial-year") !== "never" && period < counter.period) {
|
|
84
|
+
throw new Error(`Date ${date} belongs to ${period}, which is before the current period ${counter.period}`);
|
|
85
|
+
}
|
|
86
|
+
seq = 1;
|
|
87
|
+
} else seq = counter.seq + 1;
|
|
88
|
+
return { number: format(seq, date, f), counter: { period, seq } };
|
|
89
|
+
}
|
|
90
|
+
function checkGstFormat(number) {
|
|
91
|
+
const problems = [];
|
|
92
|
+
if (number.length === 0) problems.push("Invoice number is empty");
|
|
93
|
+
if (number.length > 16) problems.push(`Invoice number is ${number.length} characters, the limit is 16`);
|
|
94
|
+
if (/[^A-Za-z0-9/-]/.test(number)) problems.push('Only letters, digits, "/" and "-" are allowed');
|
|
95
|
+
return { valid: problems.length === 0, problems };
|
|
96
|
+
}
|
|
97
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
98
|
+
0 && (module.exports = {
|
|
99
|
+
checkGstFormat,
|
|
100
|
+
financialYear,
|
|
101
|
+
format,
|
|
102
|
+
next
|
|
103
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates are "YYYY-MM-DD" strings (plain calendar dates, no time zones).
|
|
3
|
+
*
|
|
4
|
+
* The package holds no state. You keep a small counter `{ period, seq }` in your database, call `next`
|
|
5
|
+
* inside the same transaction that saves the invoice, and store the counter it returns.
|
|
6
|
+
*/
|
|
7
|
+
type ResetPolicy = "financial-year" | "calendar-year" | "month" | "never";
|
|
8
|
+
interface NumberFormat {
|
|
9
|
+
/**
|
|
10
|
+
* Pattern with tokens: {FY} 2025-26, {FY2} 2526, {YYYY}, {YY}, {MM}, and {SEQ:n} for the sequence
|
|
11
|
+
* padded to n digits ({SEQ} is unpadded). Example: "INV/{FY}/{SEQ:5}" -> "INV/2025-26/00042".
|
|
12
|
+
*/
|
|
13
|
+
pattern: string;
|
|
14
|
+
/** When the sequence goes back to 1. Default "financial-year". */
|
|
15
|
+
reset?: ResetPolicy;
|
|
16
|
+
/** First month of the financial year. Default 4 (April), as in India. */
|
|
17
|
+
fyStartMonth?: number;
|
|
18
|
+
}
|
|
19
|
+
interface Counter {
|
|
20
|
+
/** The period the sequence belongs to, e.g. "2025-26". */
|
|
21
|
+
period: string;
|
|
22
|
+
/** The last sequence number used. */
|
|
23
|
+
seq: number;
|
|
24
|
+
}
|
|
25
|
+
/** Financial year label: financialYear("2025-03-31") -> "2024-25", financialYear("2025-04-01") -> "2025-26". */
|
|
26
|
+
declare function financialYear(date: string, fyStartMonth?: number): string;
|
|
27
|
+
/** Builds the invoice number for a given sequence and date. */
|
|
28
|
+
declare function format(seq: number, date: string, f: NumberFormat): string;
|
|
29
|
+
/**
|
|
30
|
+
* Gives the next number and the updated counter. Pass `null` for the very first invoice.
|
|
31
|
+
* If the date falls in a new period (new financial year, month...) the sequence restarts at 1.
|
|
32
|
+
* Dates earlier than the counter's period are refused, so a back-dated invoice cannot reuse a number.
|
|
33
|
+
*/
|
|
34
|
+
declare function next(counter: Counter | null, date: string, f: NumberFormat): {
|
|
35
|
+
number: string;
|
|
36
|
+
counter: Counter;
|
|
37
|
+
};
|
|
38
|
+
interface GstCheck {
|
|
39
|
+
valid: boolean;
|
|
40
|
+
problems: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Checks the Indian GST rule for invoice numbers (Rule 46): at most 16 characters, only letters,
|
|
44
|
+
* digits, "/" and "-". Uniqueness within the financial year is up to your counter.
|
|
45
|
+
*/
|
|
46
|
+
declare function checkGstFormat(number: string): GstCheck;
|
|
47
|
+
|
|
48
|
+
export { type Counter, type GstCheck, type NumberFormat, type ResetPolicy, checkGstFormat, financialYear, format, next };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates are "YYYY-MM-DD" strings (plain calendar dates, no time zones).
|
|
3
|
+
*
|
|
4
|
+
* The package holds no state. You keep a small counter `{ period, seq }` in your database, call `next`
|
|
5
|
+
* inside the same transaction that saves the invoice, and store the counter it returns.
|
|
6
|
+
*/
|
|
7
|
+
type ResetPolicy = "financial-year" | "calendar-year" | "month" | "never";
|
|
8
|
+
interface NumberFormat {
|
|
9
|
+
/**
|
|
10
|
+
* Pattern with tokens: {FY} 2025-26, {FY2} 2526, {YYYY}, {YY}, {MM}, and {SEQ:n} for the sequence
|
|
11
|
+
* padded to n digits ({SEQ} is unpadded). Example: "INV/{FY}/{SEQ:5}" -> "INV/2025-26/00042".
|
|
12
|
+
*/
|
|
13
|
+
pattern: string;
|
|
14
|
+
/** When the sequence goes back to 1. Default "financial-year". */
|
|
15
|
+
reset?: ResetPolicy;
|
|
16
|
+
/** First month of the financial year. Default 4 (April), as in India. */
|
|
17
|
+
fyStartMonth?: number;
|
|
18
|
+
}
|
|
19
|
+
interface Counter {
|
|
20
|
+
/** The period the sequence belongs to, e.g. "2025-26". */
|
|
21
|
+
period: string;
|
|
22
|
+
/** The last sequence number used. */
|
|
23
|
+
seq: number;
|
|
24
|
+
}
|
|
25
|
+
/** Financial year label: financialYear("2025-03-31") -> "2024-25", financialYear("2025-04-01") -> "2025-26". */
|
|
26
|
+
declare function financialYear(date: string, fyStartMonth?: number): string;
|
|
27
|
+
/** Builds the invoice number for a given sequence and date. */
|
|
28
|
+
declare function format(seq: number, date: string, f: NumberFormat): string;
|
|
29
|
+
/**
|
|
30
|
+
* Gives the next number and the updated counter. Pass `null` for the very first invoice.
|
|
31
|
+
* If the date falls in a new period (new financial year, month...) the sequence restarts at 1.
|
|
32
|
+
* Dates earlier than the counter's period are refused, so a back-dated invoice cannot reuse a number.
|
|
33
|
+
*/
|
|
34
|
+
declare function next(counter: Counter | null, date: string, f: NumberFormat): {
|
|
35
|
+
number: string;
|
|
36
|
+
counter: Counter;
|
|
37
|
+
};
|
|
38
|
+
interface GstCheck {
|
|
39
|
+
valid: boolean;
|
|
40
|
+
problems: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Checks the Indian GST rule for invoice numbers (Rule 46): at most 16 characters, only letters,
|
|
44
|
+
* digits, "/" and "-". Uniqueness within the financial year is up to your counter.
|
|
45
|
+
*/
|
|
46
|
+
declare function checkGstFormat(number: string): GstCheck;
|
|
47
|
+
|
|
48
|
+
export { type Counter, type GstCheck, type NumberFormat, type ResetPolicy, checkGstFormat, financialYear, format, next };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function parseDate(date) {
|
|
3
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
4
|
+
if (!m) throw new Error(`Invalid date "${date}", expected YYYY-MM-DD`);
|
|
5
|
+
const [y, mo, d] = [+m[1], +m[2], +m[3]];
|
|
6
|
+
if (new Date(Date.UTC(y, mo - 1, d)).toISOString().slice(0, 10) !== date) throw new Error(`Invalid date "${date}"`);
|
|
7
|
+
return { y, m: mo };
|
|
8
|
+
}
|
|
9
|
+
function financialYear(date, fyStartMonth = 4) {
|
|
10
|
+
if (!Number.isInteger(fyStartMonth) || fyStartMonth < 1 || fyStartMonth > 12) throw new Error("fyStartMonth must be between 1 and 12");
|
|
11
|
+
const { y, m } = parseDate(date);
|
|
12
|
+
if (fyStartMonth === 1) return String(y);
|
|
13
|
+
const start = m >= fyStartMonth ? y : y - 1;
|
|
14
|
+
return `${start}-${String((start + 1) % 100).padStart(2, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
function periodKey(date, f) {
|
|
17
|
+
const { y, m } = parseDate(date);
|
|
18
|
+
switch (f.reset ?? "financial-year") {
|
|
19
|
+
case "financial-year":
|
|
20
|
+
return financialYear(date, f.fyStartMonth ?? 4);
|
|
21
|
+
case "calendar-year":
|
|
22
|
+
return String(y);
|
|
23
|
+
case "month":
|
|
24
|
+
return `${y}-${String(m).padStart(2, "0")}`;
|
|
25
|
+
case "never":
|
|
26
|
+
return "all";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var TOKEN = /\{(FY2|FY|YYYY|YY|MM|SEQ)(?::(\d{1,2}))?\}/g;
|
|
30
|
+
function format(seq, date, f) {
|
|
31
|
+
if (!Number.isInteger(seq) || seq < 1) throw new Error("seq must be a whole number of at least 1");
|
|
32
|
+
const { y, m } = parseDate(date);
|
|
33
|
+
const fy = financialYear(date, f.fyStartMonth ?? 4);
|
|
34
|
+
return f.pattern.replace(TOKEN, (_, token, width) => {
|
|
35
|
+
switch (token) {
|
|
36
|
+
case "FY":
|
|
37
|
+
return fy;
|
|
38
|
+
case "FY2":
|
|
39
|
+
return fy.includes("-") ? fy.slice(2, 4) + fy.slice(5) : fy.slice(2);
|
|
40
|
+
case "YYYY":
|
|
41
|
+
return String(y);
|
|
42
|
+
case "YY":
|
|
43
|
+
return String(y % 100).padStart(2, "0");
|
|
44
|
+
case "MM":
|
|
45
|
+
return String(m).padStart(2, "0");
|
|
46
|
+
default:
|
|
47
|
+
return String(seq).padStart(width ? Number(width) : 0, "0");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function next(counter, date, f) {
|
|
52
|
+
if (!/\{SEQ(?::\d{1,2})?\}/.test(f.pattern)) throw new Error("pattern must contain {SEQ} or {SEQ:n}");
|
|
53
|
+
const period = periodKey(date, f);
|
|
54
|
+
let seq;
|
|
55
|
+
if (!counter || counter.period !== period) {
|
|
56
|
+
if (counter && (f.reset ?? "financial-year") !== "never" && period < counter.period) {
|
|
57
|
+
throw new Error(`Date ${date} belongs to ${period}, which is before the current period ${counter.period}`);
|
|
58
|
+
}
|
|
59
|
+
seq = 1;
|
|
60
|
+
} else seq = counter.seq + 1;
|
|
61
|
+
return { number: format(seq, date, f), counter: { period, seq } };
|
|
62
|
+
}
|
|
63
|
+
function checkGstFormat(number) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
if (number.length === 0) problems.push("Invoice number is empty");
|
|
66
|
+
if (number.length > 16) problems.push(`Invoice number is ${number.length} characters, the limit is 16`);
|
|
67
|
+
if (/[^A-Za-z0-9/-]/.test(number)) problems.push('Only letters, digits, "/" and "-" are allowed');
|
|
68
|
+
return { valid: problems.length === 0, problems };
|
|
69
|
+
}
|
|
70
|
+
export {
|
|
71
|
+
checkGstFormat,
|
|
72
|
+
financialYear,
|
|
73
|
+
format,
|
|
74
|
+
next
|
|
75
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenuinebit/invoice-number",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sequential invoice numbers with financial-year resets and GST-compliant format checks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
}
|
|
6
29
|
}
|