주문에서 스페인 송장 발행: 법정 번호, 간이/정식, 정정 송장, VeriFactu 방식 해시 체인
설치
genpm add @core/invoices포함 내용
- src/lib/invoices/에 소스 코드, 파일 8개. (30.3kB)
- src/lib/invoices/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: INVOICE_ISSUER_NIF, INVOICE_ISSUER_NAME, INVOICE_ISSUER_ADDRESS, INVOICE_SERIES, INVOICE_VAT_RATE, INVOICE_RETAIL, INVOICE_QR_ENV.
- @core/contracts, @core/db, @core/orders을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/invoices에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/invoices — rules for AI agents
Purpose
Spanish invoices for paid orders (@core/orders): correlative numbering per series and year without gaps, simplified
invoice up to the legal limit and full invoice above it, credit notes (rectificativas por diferencias) for refunds in
their own series, full invoices that replace simplified ones when the customer gives their tax details, printable HTML
with the mandatory content, and VeriFactu-style registration records: each record carries the SHA-256 hash of the
previous one (format verified against the AEAT example) and the AEAT verification QR URL. Invoices are never edited
or deleted. Tables: invoices, invoice_counters, invoice_records.
Scope of 1.0 (tell the person): this package prepares the data required by RD 1007/2023 and Order HAC/1177/2024, but it does not yet sign records (non-VeriFactu mode) nor send them to the AEAT (VeriFactu mode), and it is not a certified billing system. Deadlines changed several times (RD-ley 15/2025 set 2027; in October 2026 the Ministry announced a move to October 2028): check the current official calendar with the AEAT or a tax advisor.
Map
index.ts—invoiceOrder,rectifyRefund,rectifyInFull,remainingToRectify,replaceSimplified,verifyChain,invoiceHtml,invoiceQrUrl,invoicesAdminResource.invoices.ts— issuing and order events.verifactu.ts— hash input, hash, Madrid dates, amounts, QR URL.html.ts— printable invoice.
Integration
- Env:
INVOICE_ISSUER_NIF,INVOICE_ISSUER_NAME,INVOICE_ISSUER_ADDRESS; optionalINVOICE_SERIES(defaultA; credit notes use<series>R),INVOICE_VAT_RATE(default 21, prices include VAT),INVOICE_RETAIL=1(simplified limit 3,000 € instead of 400 €),INVOICE_QR_ENV=testwhile testing. - Migrations as in
src/lib/db/AGENTS.md. Import@/lib/invoicesonce at startup (e.g.src/genpm/commerce.ts): it issues invoices when orders are paid and credit notes when they are refunded (background jobs, retried). - Admin: add
invoicesAdminResource({ printPath: (i) => \/invoices/${i.number}` })tosrc/genpm/admin.ts`. - Printable page for the owner (protected by
invoices:read) or the customer (signed order link): returnnew Response(invoiceHtml(inv, { qrImageSrc }), { headers: { 'content-type': 'text/html' } }). Generate the QR image frominvoiceQrUrl(inv)with a QR library in the app. - Verify: pay a test order → invoice
A<year>-000001; refund part → credit noteAR<year>-000001with a negative total;verifyChain(nif)returnsok: true.
Conventions
- Lines add up to the total: products at their pre-discount amount, one "Discount" line, "Shipping (<method>)" and "Tax" if added on top.
- "Rectify in full" (admin,
rectifyInFull) credits only what earlier credit notes left; it refuses when nothing remains. - All lines use one VAT rate in 1.0; mixed rates (reduced, exempt) need per-line taxes before using this package.
- Issue invoices in EUR only; convert other currencies before.
- Keep invoices and records at least as long as tax law requires (typically 4–6 years); never prune these tables.
Don't
- Don't edit, renumber or delete invoices or records; correct with credit notes.
- Don't print "VERI*FACTU" on invoices until records are actually sent to the AEAT.
- Don't present this package as certified or as legal/tax advice.
# @core/invoices — rules for AI agents
## Purpose
Spanish invoices for paid orders (@core/orders): correlative numbering per series and year without gaps, simplified
invoice up to the legal limit and full invoice above it, credit notes (rectificativas por diferencias) for refunds in
their own series, full invoices that replace simplified ones when the customer gives their tax details, printable HTML
with the mandatory content, and VeriFactu-style registration records: each record carries the SHA-256 hash of the
previous one (format verified against the AEAT example) and the AEAT verification QR URL. Invoices are never edited
or deleted. Tables: `invoices`, `invoice_counters`, `invoice_records`.
**Scope of 1.0 (tell the person):** this package prepares the data required by RD 1007/2023 and Order HAC/1177/2024,
but it does not yet sign records (non-VeriFactu mode) nor send them to the AEAT (VeriFactu mode), and it is not a
certified billing system. Deadlines changed several times (RD-ley 15/2025 set 2027; in October 2026 the Ministry
announced a move to October 2028): check the current official calendar with the AEAT or a tax advisor.
## Map
- `index.ts` — `invoiceOrder`, `rectifyRefund`, `rectifyInFull`, `remainingToRectify`, `replaceSimplified`, `verifyChain`, `invoiceHtml`, `invoiceQrUrl`, `invoicesAdminResource`.
- `invoices.ts` — issuing and order events. `verifactu.ts` — hash input, hash, Madrid dates, amounts, QR URL. `html.ts` — printable invoice.
## Integration
1. Env: `INVOICE_ISSUER_NIF`, `INVOICE_ISSUER_NAME`, `INVOICE_ISSUER_ADDRESS`; optional `INVOICE_SERIES` (default `A`;
credit notes use `<series>R`), `INVOICE_VAT_RATE` (default 21, prices include VAT), `INVOICE_RETAIL=1` (simplified
limit 3,000 € instead of 400 €), `INVOICE_QR_ENV=test` while testing.
2. Migrations as in `src/lib/db/AGENTS.md`. Import `@/lib/invoices` once at startup (e.g. `src/genpm/commerce.ts`):
it issues invoices when orders are paid and credit notes when they are refunded (background jobs, retried).
3. Admin: add `invoicesAdminResource({ printPath: (i) => \`/invoices/${i.number}\` })` to `src/genpm/admin.ts`.
4. Printable page for the owner (protected by `invoices:read`) or the customer (signed order link):
return `new Response(invoiceHtml(inv, { qrImageSrc }), { headers: { 'content-type': 'text/html' } })`. Generate the QR
image from `invoiceQrUrl(inv)` with a QR library in the app.
5. Verify: pay a test order → invoice `A<year>-000001`; refund part → credit note `AR<year>-000001` with a negative total;
`verifyChain(nif)` returns `ok: true`.
## Conventions
- Lines add up to the total: products at their pre-discount amount, one "Discount" line, "Shipping (<method>)" and "Tax" if added on top.
- "Rectify in full" (admin, `rectifyInFull`) credits only what earlier credit notes left; it refuses when nothing remains.
- All lines use one VAT rate in 1.0; mixed rates (reduced, exempt) need per-line taxes before using this package.
- Issue invoices in EUR only; convert other currencies before.
- Keep invoices and records at least as long as tax law requires (typically 4–6 years); never prune these tables.
## Don't
- Don't edit, renumber or delete invoices or records; correct with credit notes.
- Don't print "VERI*FACTU" on invoices until records are actually sent to the AEAT.
- Don't present this package as certified or as legal/tax advice.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Emisión de facturas españolas: numeración correlativa por serie y año, simplificada o completa según el importe,
// rectificativas por diferencias en reembolsos, sustitución de simplificadas por completas y registro encadenado.
// Las facturas nunca se borran ni se editan: se corrigen con rectificativas.
import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { type Executor, getDb, withTransaction } from '../db/index.ts';
import { getOrder, onOrderEvent, type OrderDetail } from '../orders/index.ts';
import { type Invoice, type InvoiceLine, invoiceCounters, invoiceRecords, invoices, type Party } from './schema.ts';
import { type AltaFields, amountText, huella, madridTimestamp, qrUrl, spanishDate } from './verifactu.ts';
export class InvoiceError extends Error {
constructor(
readonly code: 'invalid' | 'not_found' | 'invalid_state' | 'config' | 'forbidden',
message: string = code,
) {
super(message);
}
}
/** Emisor desde el entorno: `INVOICE_ISSUER_NIF`, `INVOICE_ISSUER_NAME`, `INVOICE_ISSUER_ADDRESS`. */
export function issuer(): Party & { taxId: string } {
const taxId = process.env.INVOICE_ISSUER_NIF?.trim().toUpperCase();
const name = process.env.INVOICE_ISSUER_NAME?.trim();
if (!taxId || !/^[A-Z0-9]{9}$/.test(taxId) || !name) throw new InvoiceError('config', 'set INVOICE_ISSUER_NIF (9 characters) and INVOICE_ISSUER_NAME');
return { name, taxId, address: process.env.INVOICE_ISSUER_ADDRESS?.trim() || null, country: 'ES' };
}
const vatRate = () => Number(process.env.INVOICE_VAT_RATE ?? 21);
/** Límite de la factura simplificada (IVA incluido): 400 € en general; 3000 € en comercio minorista (`INVOICE_RETAIL=1`). */
export const simplifiedLimit = () => (process.env.INVOICE_RETAIL === '1' ? 300_000 : 40_000);
const series = (rectifying: boolean) => {
const s = (process.env.INVOICE_SERIES ?? 'A').toUpperCase();
if (!/^[A-Z0-9]{1,6}$/.test(s)) throw new InvoiceError('config', 'INVOICE_SERIES must be 1–6 letters or digits');
// Las rectificativas van en serie propia (obligatorio).
return rectifying ? `${s}R` : s;
};
/** Base e IVA desde un total con IVA incluido (redondeo al céntimo; base + IVA = total). */
export function split(totalWithVat: number, rate = vatRate()): { base: number; vat: number } {
const base = Math.round((totalWithVat * 100) / (100 + rate));
return { base, vat: totalWithVat - base };
}
/** Siguiente número correlativo de la serie en el año, con la fila del contador bloqueada (sin huecos ni duplicados). */
async function nextNumber(tx: Executor, s: string, year: number): Promise<string> {
await tx.insert(invoiceCounters).values({ series: s, year, last: 0 }).onConflictDoNothing();
const [c] = await tx.select().from(invoiceCounters).where(and(eq(invoiceCounters.series, s), eq(invoiceCounters.year, year))).for('update');
const n = c!.last + 1;
await tx.update(invoiceCounters).set({ last: n }).where(and(eq(invoiceCounters.series, s), eq(invoiceCounters.year, year)));
return `${s}${year}-${String(n).padStart(6, '0')}`;
}
/** Registro de alta encadenado al último del emisor (bloquea la cadena para que dos emisiones no compartan anterior). */
async function chainAlta(tx: Executor, inv: Invoice): Promise<void> {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`invoices:${inv.issuer.taxId}`}))`);
const [prev] = await tx.select().from(invoiceRecords).where(eq(invoiceRecords.issuerTaxId, inv.issuer.taxId!)).orderBy(desc(invoiceRecords.seq)).limit(1);
const fields: AltaFields = {
IDEmisorFactura: inv.issuer.taxId!,
NumSerieFactura: inv.number,
FechaExpedicionFactura: spanishDate(inv.issuedAt),
TipoFactura: inv.type,
CuotaTotal: amountText(inv.vat),
ImporteTotal: amountText(inv.total),
Huella: prev?.huella ?? '',
FechaHoraHusoGenRegistro: madridTimestamp(new Date()),
};
await tx.insert(invoiceRecords).values({
invoiceId: inv.id,
issuerTaxId: inv.issuer.taxId!,
seq: (prev?.seq ?? 0) + 1,
kind: 'alta',
huella: await huella(fields),
previousHuella: prev?.huella ?? null,
generatedAt: fields.FechaHoraHusoGenRegistro,
fields,
});
}
const Recipient = z.object({ name: z.string().trim().min(1).max(120), taxId: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{8,14}$/), address: z.string().trim().min(5).max(300), country: z.string().length(2).default('ES') });
type Draft = { type: Invoice['type']; orderId: string | null; rectifiesId?: string | null; reason?: string | null; recipient: Party | null; lines: InvoiceLine[]; currency: string; total: number };
async function issue(tx: Executor, d: Draft): Promise<Invoice> {
if (d.currency !== 'EUR') throw new InvoiceError('invalid', 'Spanish invoices are issued in EUR (convert before issuing)');
const now = new Date();
const rate = vatRate();
const { base, vat } = split(d.total, rate);
const number = await nextNumber(tx, series(d.type.startsWith('R')), Number(spanishDate(now).slice(6)));
const [inv] = await tx
.insert(invoices)
.values({ number, series: number.replace(/\d{4}-\d+$/, ''), type: d.type, issuedAt: now, orderId: d.orderId, rectifiesId: d.rectifiesId ?? null, reason: d.reason ?? null, issuer: issuer(), recipient: d.recipient, lines: d.lines, currency: d.currency, base, vatRate: rate, vat, total: d.total })
.returning();
await chainAlta(tx, inv!);
return inv!;
}
const tx = <T>(db: Executor, fn: (t: Executor) => Promise<T>) => ('rollback' in db ? fn(db) : withTransaction(fn, db as Parameters<typeof withTransaction>[1]));
/**
* Líneas de la factura: cada producto por su importe antes de descuentos (`subtotal`), una línea de descuento con el
* total descontado, el envío (con el nombre del método) y los impuestos añadidos si los hay. Suman exactamente `total`.
*/
export const linesOf = (o: OrderDetail): InvoiceLine[] => [
...o.lines.map((l) => ({ description: `${l.name}${l.title ? ` · ${l.title}` : ''}`, quantity: l.quantity, unitPrice: l.unitPrice, total: l.subtotal })),
...(o.discountTotal ? [{ description: 'Discount', quantity: 1, unitPrice: -o.discountTotal, total: -o.discountTotal }] : []),
...(o.shippingTotal ? [{ description: `Shipping${o.shippingMethod?.label ? ` (${o.shippingMethod.label})` : ''}`, quantity: 1, unitPrice: o.shippingTotal, total: o.shippingTotal }] : []),
...(o.taxTotal ? [{ description: 'Tax', quantity: 1, unitPrice: o.taxTotal, total: o.taxTotal }] : []),
];
const recipientOf = (o: OrderDetail): Party | null => {
const a = o.billingAddress ?? o.shippingAddress;
return a ? { name: a.company || a.name, taxId: a.taxId ?? null, address: [a.line1, a.line2, a.postalCode, a.city, a.region].filter(Boolean).join(', '), country: a.country } : null;
};
/** Factura de un pedido pagado (idempotente): simplificada hasta el límite, completa por encima. */
export async function invoiceOrder(orderId: string, db: Executor = getDb()): Promise<Invoice> {
return tx(db, async (t) => {
const [existing] = await t.select().from(invoices).where(and(eq(invoices.orderId, orderId), sql`${invoices.type} in ('F1','F2')`));
if (existing) return existing;
const o = await getOrder(orderId, t);
if (!o) throw new InvoiceError('not_found', `order ${orderId} not found`);
if (o.status === 'pending' || o.status === 'cancelled') throw new InvoiceError('invalid_state', 'only paid orders are invoiced');
const simplified = o.total <= simplifiedLimit();
return issue(t, { type: simplified ? 'F2' : 'F1', orderId, recipient: simplified ? null : recipientOf(o), lines: linesOf(o), currency: o.currency, total: o.total });
});
}
/** Rectificativa por diferencias (importe negativo) por un reembolso; `refundId` evita duplicados. */
export async function rectifyRefund(orderId: string, refundId: string, amount: number, db: Executor = getDb()): Promise<Invoice> {
if (!Number.isInteger(amount) || amount <= 0) throw new InvoiceError('invalid', 'refund amount must be positive cents');
return tx(db, async (t) => {
const reason = `refund:${refundId}`;
const [done] = await t.select().from(invoices).where(and(eq(invoices.orderId, orderId), eq(invoices.reason, reason)));
if (done) return done;
const [original] = await t.select().from(invoices).where(and(eq(invoices.orderId, orderId), sql`${invoices.type} in ('F1','F2','F3')`)).orderBy(desc(invoices.issuedAt)).limit(1);
if (!original) throw new InvoiceError('invalid_state', 'the order has no invoice to rectify');
return issue(t, {
type: original.type === 'F2' ? 'R5' : 'R1',
orderId,
rectifiesId: original.id,
reason,
recipient: original.recipient,
lines: [{ description: `Refund of invoice ${original.number}`, quantity: 1, unitPrice: -amount, total: -amount }],
currency: original.currency,
total: -amount,
});
});
}
/** Importe aún sin rectificar del pedido: su última factura (F1/F2/F3) más las rectificativas (negativas) ya emitidas. */
export async function remainingToRectify(orderId: string, db: Executor = getDb()): Promise<number> {
const [original] = await db.select().from(invoices).where(and(eq(invoices.orderId, orderId), sql`${invoices.type} in ('F1','F2','F3')`)).orderBy(desc(invoices.issuedAt)).limit(1);
if (!original) return 0;
const [r] = await db.select({ n: sql<number>`coalesce(sum(${invoices.total}), 0)`.mapWith(Number) }).from(invoices).where(and(eq(invoices.orderId, orderId), sql`${invoices.type} in ('R1','R5')`));
return original.total + (r?.n ?? 0);
}
/** Rectifica lo que quede del pedido de la factura `invoiceId` (abono total); falla si ya no queda nada. */
export async function rectifyInFull(invoiceId: string, db: Executor = getDb()): Promise<Invoice> {
return tx(db, async (t) => {
const [inv] = await t.select().from(invoices).where(eq(invoices.id, invoiceId));
if (!inv?.orderId) throw new InvoiceError('not_found');
// Serializa los abonos del pedido para que dos simultáneos no rectifiquen dos veces lo mismo.
await t.execute(sql`select pg_advisory_xact_lock(hashtext(${`invoices:rectify:${inv.orderId}`}))`);
const remaining = await remainingToRectify(inv.orderId, t);
if (remaining <= 0) throw new InvoiceError('invalid_state', 'nothing left to rectify on this order');
return rectifyRefund(inv.orderId, `manual-${inv.id}-${remaining}`, remaining, t);
});
}
/** Factura completa (F3) que sustituye a una simplificada cuando el cliente aporta sus datos fiscales. */
export async function replaceSimplified(invoiceId: string, recipient: z.input<typeof Recipient>, db: Executor = getDb()): Promise<Invoice> {
const r = Recipient.parse(recipient);
return tx(db, async (t) => {
const [s] = await t.select().from(invoices).where(eq(invoices.id, invoiceId));
if (!s) throw new InvoiceError('not_found');
if (s.type !== 'F2') throw new InvoiceError('invalid_state', 'only simplified invoices can be replaced');
const [already] = await t.select().from(invoices).where(and(eq(invoices.rectifiesId, s.id), eq(invoices.type, 'F3')));
if (already) throw new InvoiceError('invalid_state', `already replaced by ${already.number}`);
return issue(t, { type: 'F3', orderId: s.orderId, rectifiesId: s.id, reason: 'replaces-simplified', recipient: r, lines: s.lines, currency: s.currency, total: s.total });
});
}
/** Comprueba que la cadena de registros del emisor no se ha alterado (recalcula cada huella y su enlace). */
export async function verifyChain(issuerTaxId: string, db: Executor = getDb()): Promise<{ ok: boolean; records: number; brokenAt?: number }> {
const rows = await db.select().from(invoiceRecords).where(eq(invoiceRecords.issuerTaxId, issuerTaxId)).orderBy(invoiceRecords.seq);
let prev = '';
for (const r of rows) {
const f = r.fields as unknown as AltaFields;
if ((f.Huella ?? '') !== prev || (await huella(f)) !== r.huella) return { ok: false, records: rows.length, brokenAt: r.seq };
prev = r.huella;
}
return { ok: true, records: rows.length };
}
/** URL del QR de cotejo de la AEAT para imprimir en la factura (`INVOICE_QR_ENV=test` usa el entorno de pruebas). */
export const invoiceQrUrl = (inv: Invoice) =>
qrUrl({ nif: inv.issuer.taxId!, number: inv.number, date: spanishDate(inv.issuedAt), total: amountText(inv.total) }, process.env.INVOICE_QR_ENV === 'test' ? 'test' : 'production');
/** Emite la factura al pagarse el pedido y la rectificativa en cada reembolso (vía @core/orders, con reintentos). */
export function registerInvoicing(): void {
onOrderEvent('paid', 'invoices.issue', async (order) => {
await invoiceOrder(order.id);
});
onOrderEvent('refunded', 'invoices.rectify', async (order, data) => {
const refundId = String(data.refundId ?? '');
const refund = order.refunds.find((r) => r.id === refundId);
if (refund) await rectifyRefund(order.id, refund.id, refund.amount);
});
}
registerInvoicing();
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.1 | 6912dca | 4시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 사용하는 패키지 (0)
- 아직 이 패키지에 의존하는 공개 패키지가 없습니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.1 → 6912dca67ec74a456be4abfe09c9584156cc7e4a · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?