@perrylink/dsh-einvoice 0.0.0-stage → 0.1.0-alpha.1

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.
@@ -0,0 +1,421 @@
1
+ /** OFD (ZIP + XML) and standalone XML reader for SKU-04 S1. */
2
+ import { unzipSync } from 'fflate';
3
+ import { XMLParser } from 'fast-xml-parser';
4
+ import { InvoiceError, amountToCents, normalizeDate, normalizeTaxId, } from '../core/invoice.js';
5
+ export const PARSER_ID = 'ofd-zip-xml';
6
+ export const PARSER_VERSION = 'ofd-zip-xml@1';
7
+ const xmlParser = new XMLParser({
8
+ ignoreAttributes: false,
9
+ attributeNamePrefix: '@_',
10
+ removeNSPrefix: true,
11
+ parseTagValue: false,
12
+ trimValues: true,
13
+ processEntities: false,
14
+ allowBooleanAttributes: false,
15
+ });
16
+ function isObject(value) {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
19
+ function asArray(value) {
20
+ if (value === undefined || value === null)
21
+ return [];
22
+ return Array.isArray(value) ? value : [value];
23
+ }
24
+ function asObject(value) {
25
+ if (Array.isArray(value))
26
+ return asObject(value[0]);
27
+ return isObject(value) ? value : undefined;
28
+ }
29
+ function scalar(value) {
30
+ if (value === undefined || value === null)
31
+ return '';
32
+ if (typeof value === 'string')
33
+ return value.trim();
34
+ if (typeof value === 'number' || typeof value === 'boolean')
35
+ return String(value);
36
+ if (Array.isArray(value))
37
+ return value.map(scalar).join('');
38
+ if (isObject(value)) {
39
+ const text = value['#text'];
40
+ if (text !== undefined)
41
+ return scalar(text);
42
+ }
43
+ return '';
44
+ }
45
+ function child(node, key) {
46
+ return node ? asObject(node[key]) : undefined;
47
+ }
48
+ function textAt(node, ...path) {
49
+ let current = node;
50
+ for (const key of path) {
51
+ const currentObject = asObject(current);
52
+ if (!currentObject)
53
+ return '';
54
+ current = currentObject[key];
55
+ }
56
+ return scalar(current);
57
+ }
58
+ function unknownTopLevel(root, known) {
59
+ const knownSet = new Set(known);
60
+ const unknown = {};
61
+ for (const [key, value] of Object.entries(root)) {
62
+ if (!knownSet.has(key))
63
+ unknown[key] = value;
64
+ }
65
+ return unknown;
66
+ }
67
+ function parseXml(text) {
68
+ const parsed = xmlParser.parse(text);
69
+ if (!isObject(parsed))
70
+ throw new InvoiceError('E_INV_XML_SCHEMA_DRIFT', 'XML root is not an object', { needs_review: true });
71
+ return parsed;
72
+ }
73
+ const OLD_KNOWN = [
74
+ 'DocID', 'InvoiceCode', 'InvoiceNo', 'TypeCode', 'IssueDate', 'InvoiceCheckCode',
75
+ 'MachineNo', 'TaxControlCode', 'Buyer', 'Tpvn', 'Seller', 'TaxInclusiveTotalAmount',
76
+ 'Note', 'InvoiceClerk', 'Payee', 'Checker', 'TaxTotalAmount', 'TaxExclusiveTotalAmount',
77
+ 'GraphCode', 'InvoiceSIA1', 'InvoiceSIA2', 'Signature', 'GoodsInfos', '@_Version',
78
+ ];
79
+ function parseOldInvoice(root) {
80
+ const buyer = child(root, 'Buyer');
81
+ const seller = child(root, 'Seller');
82
+ const buyerName = textAt(buyer, 'BuyerName');
83
+ const sellerName = textAt(seller, 'SellerName');
84
+ const buyerTaxId = normalizeTaxId(textAt(buyer, 'BuyerTaxID'));
85
+ const sellerTaxId = normalizeTaxId(textAt(seller, 'SellerTaxID'));
86
+ const issueDate = normalizeDate(textAt(root, 'IssueDate'));
87
+ const amountCents = amountToCents(textAt(root, 'TaxExclusiveTotalAmount'));
88
+ const taxCents = amountToCents(textAt(root, 'TaxTotalAmount'));
89
+ const totalCents = amountToCents(textAt(root, 'TaxInclusiveTotalAmount'));
90
+ const goodsInfo = asArray(child(root, 'GoodsInfos')?.GoodsInfo);
91
+ const lines = goodsInfo.map((raw, index) => {
92
+ const line = asObject(raw) ?? {};
93
+ const amount = textAt(line, 'Amount');
94
+ const tax = textAt(line, 'TaxAmount');
95
+ return {
96
+ line_no: index + 1,
97
+ description: textAt(line, 'Item'),
98
+ quantity: textAt(line, 'Quantity') || undefined,
99
+ unit_price_cents: textAt(line, 'Price') ? amountToCents(textAt(line, 'Price')) : undefined,
100
+ amount_cents: amount ? amountToCents(amount) : 0,
101
+ tax_rate: textAt(line, 'TaxScheme') || undefined,
102
+ tax_cents: tax ? amountToCents(tax) : undefined,
103
+ };
104
+ });
105
+ const unknown = unknownTopLevel(root, OLD_KNOWN);
106
+ for (const [key, value] of Object.entries(buyer ?? {})) {
107
+ if (!['BuyerName', 'BuyerTaxID'].includes(key))
108
+ unknown[`Buyer.${key}`] = value;
109
+ }
110
+ for (const [key, value] of Object.entries(seller ?? {})) {
111
+ if (!['SellerName', 'SellerTaxID'].includes(key))
112
+ unknown[`Seller.${key}`] = value;
113
+ }
114
+ return {
115
+ document_kind: 'ofd-old',
116
+ invoice_no: textAt(root, 'InvoiceNo'),
117
+ invoice_code: textAt(root, 'InvoiceCode') || undefined,
118
+ issue_date: issueDate,
119
+ buyer: { name: buyerName, tax_id: buyerTaxId },
120
+ seller: { name: sellerName, tax_id: sellerTaxId },
121
+ amount_cents: amountCents,
122
+ tax_cents: taxCents,
123
+ total_cents: totalCents,
124
+ currency: 'CNY',
125
+ lines,
126
+ confidence: 1,
127
+ field_evidence: {
128
+ invoice_no: 'Attachs/original_invoice.xml:InvoiceNo',
129
+ issue_date: 'Attachs/original_invoice.xml:IssueDate',
130
+ buyer_name: 'Attachs/original_invoice.xml:Buyer/BuyerName',
131
+ buyer_tax_id: 'Attachs/original_invoice.xml:Buyer/BuyerTaxID',
132
+ seller_name: 'Attachs/original_invoice.xml:Seller/SellerName',
133
+ seller_tax_id: 'Attachs/original_invoice.xml:Seller/SellerTaxID',
134
+ amount_cents: 'Attachs/original_invoice.xml:TaxExclusiveTotalAmount',
135
+ tax_cents: 'Attachs/original_invoice.xml:TaxTotalAmount',
136
+ total_cents: 'Attachs/original_invoice.xml:TaxInclusiveTotalAmount',
137
+ },
138
+ unknown_fields: unknown,
139
+ parser_version: PARSER_VERSION,
140
+ warnings: [],
141
+ };
142
+ }
143
+ function collectRefs(value, out = []) {
144
+ if (Array.isArray(value)) {
145
+ for (const item of value)
146
+ collectRefs(item, out);
147
+ return out;
148
+ }
149
+ if (!isObject(value))
150
+ return out;
151
+ for (const [key, childValue] of Object.entries(value)) {
152
+ if (key === 'ObjectRef') {
153
+ for (const item of asArray(childValue)) {
154
+ const text = scalar(item);
155
+ if (text)
156
+ out.push(text);
157
+ }
158
+ continue;
159
+ }
160
+ collectRefs(childValue, out);
161
+ }
162
+ return out;
163
+ }
164
+ function collectTextCodes(value) {
165
+ if (Array.isArray(value))
166
+ return value.map(collectTextCodes).join('');
167
+ if (isObject(value)) {
168
+ if (value.TextCode !== undefined)
169
+ return collectTextCodes(value.TextCode);
170
+ if (value['#text'] !== undefined)
171
+ return scalar(value['#text']);
172
+ }
173
+ return scalar(value);
174
+ }
175
+ function collectTextObjects(value, out) {
176
+ if (Array.isArray(value)) {
177
+ for (const item of value)
178
+ collectTextObjects(item, out);
179
+ return;
180
+ }
181
+ if (!isObject(value))
182
+ return;
183
+ if (value.TextObject !== undefined) {
184
+ for (const raw of asArray(value.TextObject)) {
185
+ const object = asObject(raw);
186
+ if (!object)
187
+ continue;
188
+ const id = scalar(object['@_ID']);
189
+ if (!id)
190
+ continue;
191
+ out.set(id, collectTextCodes(object.TextCode));
192
+ }
193
+ }
194
+ for (const childValue of Object.values(value))
195
+ collectTextObjects(childValue, out);
196
+ }
197
+ const DIGITAL_KNOWN = [
198
+ 'InvoiceNo', 'IssueDate', 'Buyer', 'Seller', 'TaxExclusiveTotalAmount', 'TaxTotalAmount',
199
+ 'TaxInclusiveTotalAmount', 'InvoiceClerk', 'Item', 'TaxScheme', 'Amount', 'TaxAmount',
200
+ 'Price', 'Quantity', 'Note', '@_version',
201
+ ];
202
+ function digitalField(root, key, textMap) {
203
+ const raw = root[key];
204
+ if (raw === undefined || raw === null)
205
+ throw new InvoiceError('E_INV_FIELD_MISSING', `CustomTag field missing: ${key}`, { needs_review: true });
206
+ const refs = collectRefs(raw);
207
+ if (refs.length > 0) {
208
+ const resolved = refs.map((id) => textMap.get(id) ?? '').join('');
209
+ if (resolved)
210
+ return { value: resolved, evidence: `Tags/CustomTag.xml:${key} -> ${refs.map((id) => `#${id}`).join(',')}` };
211
+ }
212
+ const direct = scalar(raw);
213
+ if (direct)
214
+ return { value: direct, evidence: `Tags/CustomTag.xml:${key}` };
215
+ throw new InvoiceError('E_INV_FIELD_MISSING', `CustomTag field has no text: ${key}`, { needs_review: true });
216
+ }
217
+ function parseDigitalInvoice(root, textMap) {
218
+ const buyer = child(root, 'Buyer');
219
+ const seller = child(root, 'Seller');
220
+ const invoiceNo = digitalField(root, 'InvoiceNo', textMap);
221
+ const issueDate = digitalField(root, 'IssueDate', textMap);
222
+ const buyerName = buyer ? digitalField(buyer, 'BuyerName', textMap) : undefined;
223
+ const buyerTaxId = buyer ? digitalField(buyer, 'BuyerTaxID', textMap) : undefined;
224
+ const sellerName = seller ? digitalField(seller, 'SellerName', textMap) : undefined;
225
+ const sellerTaxId = seller ? digitalField(seller, 'SellerTaxID', textMap) : undefined;
226
+ const amount = digitalField(root, 'TaxExclusiveTotalAmount', textMap);
227
+ const tax = digitalField(root, 'TaxTotalAmount', textMap);
228
+ const total = digitalField(root, 'TaxInclusiveTotalAmount', textMap);
229
+ const itemRefs = collectRefs(root.Item);
230
+ const taxSchemeRefs = collectRefs(root.TaxScheme);
231
+ const amountRefs = collectRefs(root.Amount);
232
+ const taxAmountRefs = collectRefs(root.TaxAmount);
233
+ const priceRefs = collectRefs(root.Price);
234
+ const quantityRefs = collectRefs(root.Quantity);
235
+ const lines = itemRefs.map((id, index) => {
236
+ const description = textMap.get(id) ?? '';
237
+ const amountText = textMap.get(amountRefs[index] ?? '') ?? '';
238
+ const taxText = textMap.get(taxAmountRefs[index] ?? '') ?? '';
239
+ return {
240
+ line_no: index + 1,
241
+ description,
242
+ quantity: textMap.get(quantityRefs[index] ?? '') || undefined,
243
+ unit_price_cents: textMap.get(priceRefs[index] ?? '') ? amountToCents(textMap.get(priceRefs[index] ?? '') ?? '') : undefined,
244
+ amount_cents: amountText ? amountToCents(amountText) : 0,
245
+ tax_rate: textMap.get(taxSchemeRefs[index] ?? '') || undefined,
246
+ tax_cents: taxText ? amountToCents(taxText) : undefined,
247
+ };
248
+ });
249
+ return {
250
+ document_kind: 'ofd-digital',
251
+ invoice_no: invoiceNo.value,
252
+ issue_date: normalizeDate(issueDate.value),
253
+ buyer: {
254
+ name: buyerName?.value ?? '',
255
+ tax_id: normalizeTaxId(buyerTaxId?.value ?? ''),
256
+ },
257
+ seller: {
258
+ name: sellerName?.value ?? '',
259
+ tax_id: normalizeTaxId(sellerTaxId?.value ?? ''),
260
+ },
261
+ amount_cents: amountToCents(amount.value),
262
+ tax_cents: amountToCents(tax.value),
263
+ total_cents: amountToCents(total.value),
264
+ currency: 'CNY',
265
+ lines,
266
+ confidence: 1,
267
+ field_evidence: {
268
+ invoice_no: invoiceNo.evidence,
269
+ issue_date: issueDate.evidence,
270
+ buyer_name: buyerName?.evidence ?? '',
271
+ buyer_tax_id: buyerTaxId?.evidence ?? '',
272
+ seller_name: sellerName?.evidence ?? '',
273
+ seller_tax_id: sellerTaxId?.evidence ?? '',
274
+ amount_cents: amount.evidence,
275
+ tax_cents: tax.evidence,
276
+ total_cents: total.evidence,
277
+ },
278
+ unknown_fields: unknownTopLevel(root, DIGITAL_KNOWN),
279
+ parser_version: PARSER_VERSION,
280
+ warnings: [],
281
+ };
282
+ }
283
+ const GENERIC_KNOWN = [
284
+ 'InvoiceNo', 'InvoiceIssueDate', 'IssueDate', 'InvoiceTypeName', 'Seller', 'Buyer',
285
+ 'Items', 'Amount', 'TaxAmount', 'TotalAmount', 'InvoiceCode',
286
+ ];
287
+ function parseGenericInvoice(root) {
288
+ const buyer = child(root, 'Buyer');
289
+ const seller = child(root, 'Seller');
290
+ const buyerName = textAt(buyer, 'Name') || textAt(buyer, 'BuyerName');
291
+ const sellerName = textAt(seller, 'Name') || textAt(seller, 'SellerName');
292
+ const buyerTaxId = textAt(buyer, 'TaxID') || textAt(buyer, 'BuyerTaxID');
293
+ const sellerTaxId = textAt(seller, 'TaxID') || textAt(seller, 'SellerTaxID');
294
+ const issueDate = textAt(root, 'InvoiceIssueDate') || textAt(root, 'IssueDate');
295
+ const items = asArray(child(root, 'Items')?.Item);
296
+ const lines = items.map((raw, index) => {
297
+ const line = asObject(raw) ?? {};
298
+ return {
299
+ line_no: index + 1,
300
+ description: textAt(line, 'Description') || textAt(line, 'Item'),
301
+ quantity: textAt(line, 'Quantity') || undefined,
302
+ unit_price_cents: textAt(line, 'UnitPrice') ? amountToCents(textAt(line, 'UnitPrice')) : undefined,
303
+ amount_cents: textAt(line, 'Amount') ? amountToCents(textAt(line, 'Amount')) : 0,
304
+ tax_rate: textAt(line, 'TaxRate') || undefined,
305
+ tax_cents: textAt(line, 'TaxAmount') ? amountToCents(textAt(line, 'TaxAmount')) : undefined,
306
+ };
307
+ });
308
+ return {
309
+ document_kind: 'xml',
310
+ invoice_no: textAt(root, 'InvoiceNo'),
311
+ invoice_code: textAt(root, 'InvoiceCode') || undefined,
312
+ issue_date: normalizeDate(issueDate),
313
+ buyer: { name: buyerName, tax_id: normalizeTaxId(buyerTaxId) },
314
+ seller: { name: sellerName, tax_id: normalizeTaxId(sellerTaxId) },
315
+ amount_cents: amountToCents(textAt(root, 'Amount')),
316
+ tax_cents: amountToCents(textAt(root, 'TaxAmount')),
317
+ total_cents: amountToCents(textAt(root, 'TotalAmount')),
318
+ currency: 'CNY',
319
+ lines,
320
+ confidence: 1,
321
+ field_evidence: {
322
+ invoice_no: 'XML:InvoiceNo',
323
+ issue_date: 'XML:InvoiceIssueDate',
324
+ buyer_name: 'XML:Buyer/Name',
325
+ buyer_tax_id: 'XML:Buyer/TaxID',
326
+ seller_name: 'XML:Seller/Name',
327
+ seller_tax_id: 'XML:Seller/TaxID',
328
+ amount_cents: 'XML:Amount',
329
+ tax_cents: 'XML:TaxAmount',
330
+ total_cents: 'XML:TotalAmount',
331
+ },
332
+ unknown_fields: unknownTopLevel(root, GENERIC_KNOWN),
333
+ parser_version: PARSER_VERSION,
334
+ warnings: [],
335
+ };
336
+ }
337
+ function decodeEntry(entries, name) {
338
+ const entry = entries[name];
339
+ if (!entry)
340
+ throw new InvoiceError('E_INV_OFD_UNSUPPORTED', `missing OFD part: ${name}`);
341
+ return new TextDecoder('utf-8', { fatal: false }).decode(entry);
342
+ }
343
+ function findEntry(entries, suffix) {
344
+ return Object.keys(entries).find((name) => name.endsWith(suffix));
345
+ }
346
+ function parseOfd(input) {
347
+ let count = 0;
348
+ let total = 0;
349
+ const entries = unzipSync(new Uint8Array(input.buffer), {
350
+ filter(file) {
351
+ count += 1;
352
+ total += Number(file.originalSize ?? file.size ?? 0);
353
+ if (count > 1000 || total > 100 * 1024 * 1024) {
354
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'OFD archive exceeds safe entry/size limits');
355
+ }
356
+ return true;
357
+ },
358
+ });
359
+ if (!entries['OFD.xml'])
360
+ throw new InvoiceError('E_INV_OFD_UNSUPPORTED', 'missing OFD.xml');
361
+ const oldXmlName = findEntry(entries, 'Attachs/original_invoice.xml');
362
+ if (oldXmlName) {
363
+ const root = parseXml(decodeEntry(entries, oldXmlName));
364
+ const invoiceRoot = asObject(root.eInvoice) ?? asObject(root.Invoice) ?? root;
365
+ return parseOldInvoice(invoiceRoot);
366
+ }
367
+ const customTagName = findEntry(entries, 'Tags/CustomTag.xml');
368
+ if (customTagName) {
369
+ const textMap = new Map();
370
+ for (const name of Object.keys(entries)) {
371
+ if (/\/Pages\/.*\/Content\.xml$/i.test(name)) {
372
+ collectTextObjects(parseXml(decodeEntry(entries, name)), textMap);
373
+ }
374
+ }
375
+ const root = parseXml(decodeEntry(entries, customTagName));
376
+ const tagRoot = asObject(root.root) ?? root;
377
+ return parseDigitalInvoice(tagRoot, textMap);
378
+ }
379
+ for (const name of Object.keys(entries)) {
380
+ if (!name.endsWith('.xml'))
381
+ continue;
382
+ const text = decodeEntry(entries, name);
383
+ if (text.includes('<eInvoice') || text.includes('<Invoice')) {
384
+ const root = parseXml(text);
385
+ return parseGenericInvoice(asObject(root.eInvoice) ?? asObject(root.Invoice) ?? root);
386
+ }
387
+ }
388
+ throw new InvoiceError('E_INV_OFD_UNSUPPORTED', 'no recognized invoice XML in OFD');
389
+ }
390
+ function parseStandaloneXml(input) {
391
+ const text = input.buffer.toString('utf8');
392
+ if (text.length > 50 * 1024 * 1024)
393
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'XML exceeds 50MB');
394
+ const root = parseXml(text);
395
+ const invoiceRoot = asObject(root.eInvoice) ?? asObject(root.Invoice) ?? root;
396
+ if (invoiceRoot.Buyer && invoiceRoot.InvoiceNo)
397
+ return parseGenericInvoice(invoiceRoot);
398
+ if (invoiceRoot.Buyer && invoiceRoot.IssueDate && !invoiceRoot.InvoiceIssueDate)
399
+ return parseOldInvoice(invoiceRoot);
400
+ return parseGenericInvoice(invoiceRoot);
401
+ }
402
+ export const ofdZipXmlParser = {
403
+ id: PARSER_ID,
404
+ version: PARSER_VERSION,
405
+ supports(input) {
406
+ if (input.buffer.length === 0)
407
+ return false;
408
+ const first = input.buffer.subarray(0, 2).toString('latin1');
409
+ if (first === 'PK')
410
+ return true;
411
+ const text = input.buffer.subarray(0, 64).toString('utf8').trimStart();
412
+ return text.startsWith('<');
413
+ },
414
+ parse(input) {
415
+ if (input.buffer.length === 0)
416
+ throw new InvoiceError('E_INV_FILE_UNREADABLE', 'empty file');
417
+ if (input.buffer.subarray(0, 2).toString('latin1') === 'PK')
418
+ return parseOfd(input);
419
+ return parseStandaloneXml(input);
420
+ },
421
+ };
@@ -0,0 +1,57 @@
1
+ /** Pure invoice domain helpers. No DSH/Cordis imports are allowed here. */
2
+ export type InvoiceDocumentKind = 'ofd-old' | 'ofd-digital' | 'xml';
3
+ export interface InvoiceParty {
4
+ name: string;
5
+ tax_id: string;
6
+ }
7
+ export interface InvoiceLine {
8
+ line_no: number;
9
+ description: string;
10
+ quantity?: string;
11
+ unit_price_cents?: number;
12
+ amount_cents: number;
13
+ tax_rate?: string;
14
+ tax_cents?: number;
15
+ }
16
+ export interface ParsedInvoice {
17
+ document_kind: InvoiceDocumentKind;
18
+ invoice_no: string;
19
+ invoice_code?: string;
20
+ issue_date: string;
21
+ buyer: InvoiceParty;
22
+ seller: InvoiceParty;
23
+ amount_cents: number;
24
+ tax_cents: number;
25
+ total_cents: number;
26
+ currency: 'CNY';
27
+ lines: InvoiceLine[];
28
+ confidence: number;
29
+ field_evidence: Record<string, string>;
30
+ unknown_fields: Record<string, unknown>;
31
+ parser_version: string;
32
+ warnings: string[];
33
+ }
34
+ export type InvoiceLifecycle = 'received' | 'parsed' | 'verified' | 'archived' | 'quarantined';
35
+ export interface InvoiceErrorOptions {
36
+ retryable?: boolean;
37
+ needs_review?: boolean;
38
+ details?: Record<string, unknown>;
39
+ }
40
+ /** Project-convention error; DSH renders a thrown tool error as isError. */
41
+ export declare class InvoiceError extends Error {
42
+ readonly code: string;
43
+ readonly retryable: boolean;
44
+ readonly needs_review: boolean;
45
+ readonly details?: Record<string, unknown>;
46
+ constructor(code: string, message: string, options?: InvoiceErrorOptions);
47
+ }
48
+ /** Parse a decimal amount (yuan) into integer cents without floating point. */
49
+ export declare function amountToCents(raw: unknown): number;
50
+ /** Normalize the common Chinese date spellings to YYYY-MM-DD. */
51
+ export declare function normalizeDate(raw: unknown): string;
52
+ /** Validate the shape of a PRC taxpayer id without claiming checksum validity. */
53
+ export declare function normalizeTaxId(raw: unknown): string;
54
+ export declare function invoicePeriod(issueDate: string): string;
55
+ /** Ensure the eight published fields exist and are internally consistent. */
56
+ export declare function assertEightFields(invoice: ParsedInvoice): void;
57
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,118 @@
1
+ /** Pure invoice domain helpers. No DSH/Cordis imports are allowed here. */
2
+ /** Project-convention error; DSH renders a thrown tool error as isError. */
3
+ export class InvoiceError extends Error {
4
+ code;
5
+ retryable;
6
+ needs_review;
7
+ details;
8
+ constructor(code, message, options = {}) {
9
+ super(`${code}: ${message}`);
10
+ this.name = 'InvoiceError';
11
+ this.code = code;
12
+ this.retryable = options.retryable ?? false;
13
+ this.needs_review = options.needs_review ?? false;
14
+ this.details = options.details;
15
+ }
16
+ }
17
+ /** Parse a decimal amount (yuan) into integer cents without floating point. */
18
+ export function amountToCents(raw) {
19
+ if (typeof raw === 'number') {
20
+ if (!Number.isFinite(raw))
21
+ throw new InvoiceError('E_INV_FIELD_MISSING', 'amount is not finite');
22
+ return Math.round(raw * 100);
23
+ }
24
+ if (typeof raw !== 'string')
25
+ throw new InvoiceError('E_INV_FIELD_MISSING', 'amount is not a string or number');
26
+ const cleaned = raw.replace(/[¥¥,\s,]/g, '').trim();
27
+ const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(cleaned);
28
+ if (!match)
29
+ throw new InvoiceError('E_INV_FIELD_MISSING', `amount is not a decimal: ${raw}`);
30
+ const sign = match[1] === '-' ? -1n : 1n;
31
+ const whole = BigInt(match[2] ?? '0');
32
+ const fraction = BigInt((match[3] ?? '').padEnd(2, '0'));
33
+ const cents = sign * (whole * 100n + fraction);
34
+ if (cents > BigInt(Number.MAX_SAFE_INTEGER) || cents < BigInt(Number.MIN_SAFE_INTEGER)) {
35
+ throw new InvoiceError('E_INV_FIELD_MISSING', 'amount exceeds safe integer cents');
36
+ }
37
+ return Number(cents);
38
+ }
39
+ /** Normalize the common Chinese date spellings to YYYY-MM-DD. */
40
+ export function normalizeDate(raw) {
41
+ if (typeof raw !== 'string' || raw.trim() === '') {
42
+ throw new InvoiceError('E_INV_FIELD_MISSING', 'issue date is empty');
43
+ }
44
+ const value = raw.trim();
45
+ const compact = /^(\d{4})(\d{2})(\d{2})$/.exec(value);
46
+ const chinese = /^(\d{4})年(\d{1,2})月(\d{1,2})日$/.exec(value);
47
+ const dashed = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(value);
48
+ const match = dashed ?? chinese ?? compact;
49
+ if (!match)
50
+ throw new InvoiceError('E_INV_FIELD_MISSING', `unrecognized issue date: ${value}`);
51
+ const year = Number(match[1]);
52
+ const month = Number(match[2]);
53
+ const day = Number(match[3]);
54
+ const date = new Date(Date.UTC(year, month - 1, day));
55
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
56
+ throw new InvoiceError('E_INV_FIELD_MISSING', `invalid issue date: ${value}`);
57
+ }
58
+ return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
59
+ }
60
+ /** Validate the shape of a PRC taxpayer id without claiming checksum validity. */
61
+ export function normalizeTaxId(raw) {
62
+ if (typeof raw !== 'string' || raw.trim() === '') {
63
+ throw new InvoiceError('E_INV_FIELD_MISSING', 'tax id is empty');
64
+ }
65
+ const value = raw.replace(/\s/g, '').toUpperCase();
66
+ if (!/^[0-9A-Z]{15,20}$/.test(value)) {
67
+ throw new InvoiceError('E_INV_FIELD_MISSING', `tax id has an unexpected shape: ${value.slice(0, 4)}…`);
68
+ }
69
+ return value;
70
+ }
71
+ export function invoicePeriod(issueDate) {
72
+ return issueDate.slice(0, 7);
73
+ }
74
+ /** Ensure the eight published fields exist and are internally consistent. */
75
+ export function assertEightFields(invoice) {
76
+ const missing = [];
77
+ if (!invoice.invoice_no)
78
+ missing.push('invoice_no');
79
+ if (!invoice.issue_date)
80
+ missing.push('issue_date');
81
+ if (!invoice.buyer.name)
82
+ missing.push('buyer.name');
83
+ if (!invoice.buyer.tax_id)
84
+ missing.push('buyer.tax_id');
85
+ if (!invoice.seller.name)
86
+ missing.push('seller.name');
87
+ if (!invoice.seller.tax_id)
88
+ missing.push('seller.tax_id');
89
+ if (!Number.isInteger(invoice.amount_cents))
90
+ missing.push('amount_cents');
91
+ if (!Number.isInteger(invoice.tax_cents))
92
+ missing.push('tax_cents');
93
+ if (!Number.isInteger(invoice.total_cents))
94
+ missing.push('total_cents');
95
+ if (missing.length > 0) {
96
+ throw new InvoiceError('E_INV_FIELD_MISSING', `missing fields: ${missing.join(',')}`, { needs_review: true });
97
+ }
98
+ if (invoice.total_cents !== invoice.amount_cents + invoice.tax_cents) {
99
+ throw new InvoiceError('E_INV_XML_SCHEMA_DRIFT', 'total != amount + tax', {
100
+ needs_review: true,
101
+ details: {
102
+ amount_cents: invoice.amount_cents,
103
+ tax_cents: invoice.tax_cents,
104
+ total_cents: invoice.total_cents,
105
+ },
106
+ });
107
+ }
108
+ }
109
+ export function canonicalJson(value) {
110
+ if (value === null || typeof value !== 'object')
111
+ return JSON.stringify(value);
112
+ if (Array.isArray(value))
113
+ return `[${value.map(canonicalJson).join(',')}]`;
114
+ const entries = Object.entries(value)
115
+ .filter(([, v]) => v !== undefined)
116
+ .sort(([a], [b]) => a.localeCompare(b));
117
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`;
118
+ }
@@ -0,0 +1,5 @@
1
+ /** DSH Host entry for SKU-04 S1. */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ export declare const name = "dsh-einvoice";
4
+ export declare const inject: string[];
5
+ export declare function apply(ctx: Context): Promise<void>;
@@ -0,0 +1,18 @@
1
+ /** DSH Host entry for SKU-04 S1. */
2
+ import path from 'node:path';
3
+ import { createTools } from './tools.js';
4
+ import { openInvoiceTable } from './storage.js';
5
+ export const name = 'dsh-einvoice';
6
+ export const inject = ['tools', 'storageDomain'];
7
+ export async function apply(ctx) {
8
+ const baseDir = process.env.DSH_EINVOICE_HOME ??
9
+ path.join(process.env.DSH_HOME ?? process.cwd(), 'storages', 'dsh-einvoice');
10
+ const roots = (process.env.DSH_EINVOICE_INPUT_ROOTS ?? process.cwd())
11
+ .split(path.delimiter)
12
+ .map((entry) => entry.trim())
13
+ .filter(Boolean);
14
+ const table = await openInvoiceTable(ctx);
15
+ for (const tool of createTools({ table, baseDir, inputRoots: roots })) {
16
+ ctx.tools.register(tool);
17
+ }
18
+ }