includio-cms 0.27.0 → 0.28.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.
Files changed (33) hide show
  1. package/API.md +19 -3
  2. package/CHANGELOG.md +40 -0
  3. package/DOCS.md +1 -1
  4. package/dist/admin/client/shop/shop-order-detail-page.svelte +85 -0
  5. package/dist/admin/remote/shop.remote.d.ts +58 -0
  6. package/dist/admin/remote/shop.remote.js +18 -0
  7. package/dist/db-postgres/schema/shop/index.d.ts +1 -0
  8. package/dist/db-postgres/schema/shop/index.js +1 -0
  9. package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
  10. package/dist/db-postgres/schema/shop/invoice.js +27 -0
  11. package/dist/db-postgres/schema/shop/order.d.ts +70 -0
  12. package/dist/db-postgres/schema/shop/order.js +4 -0
  13. package/dist/shop/adapters/fakturownia/client.d.ts +28 -0
  14. package/dist/shop/adapters/fakturownia/client.js +67 -0
  15. package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
  16. package/dist/shop/adapters/fakturownia/index.js +36 -0
  17. package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
  18. package/dist/shop/adapters/fakturownia/payload.js +45 -0
  19. package/dist/shop/client/index.d.ts +7 -0
  20. package/dist/shop/http/checkout-handler.js +11 -0
  21. package/dist/shop/index.d.ts +4 -1
  22. package/dist/shop/index.js +3 -0
  23. package/dist/shop/nip.d.ts +12 -0
  24. package/dist/shop/nip.js +23 -0
  25. package/dist/shop/server/invoices.d.ts +64 -0
  26. package/dist/shop/server/invoices.js +237 -0
  27. package/dist/shop/server/orders.d.ts +4 -0
  28. package/dist/shop/server/orders.js +11 -0
  29. package/dist/shop/types.d.ts +67 -1
  30. package/dist/updates/0.28.0/index.d.ts +2 -0
  31. package/dist/updates/0.28.0/index.js +38 -0
  32. package/dist/updates/index.js +3 -1
  33. package/package.json +1 -1
@@ -124,6 +124,40 @@ export declare const shopOrdersTable: import("drizzle-orm/pg-core/table", { with
124
124
  identity: undefined;
125
125
  generated: undefined;
126
126
  }, {}, {}>;
127
+ customerNip: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
128
+ name: "customer_nip";
129
+ tableName: "shop_orders";
130
+ dataType: "string";
131
+ columnType: "PgText";
132
+ data: string;
133
+ driverParam: string;
134
+ notNull: false;
135
+ hasDefault: false;
136
+ isPrimaryKey: false;
137
+ isAutoincrement: false;
138
+ hasRuntimeDefault: false;
139
+ enumValues: [string, ...string[]];
140
+ baseColumn: never;
141
+ identity: undefined;
142
+ generated: undefined;
143
+ }, {}, {}>;
144
+ customerCompanyName: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
145
+ name: "customer_company_name";
146
+ tableName: "shop_orders";
147
+ dataType: "string";
148
+ columnType: "PgText";
149
+ data: string;
150
+ driverParam: string;
151
+ notNull: false;
152
+ hasDefault: false;
153
+ isPrimaryKey: false;
154
+ isAutoincrement: false;
155
+ hasRuntimeDefault: false;
156
+ enumValues: [string, ...string[]];
157
+ baseColumn: never;
158
+ identity: undefined;
159
+ generated: undefined;
160
+ }, {}, {}>;
127
161
  shippingAddress: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
128
162
  name: "shipping_address";
129
163
  tableName: "shop_orders";
@@ -143,6 +177,42 @@ export declare const shopOrdersTable: import("drizzle-orm/pg-core/table", { with
143
177
  }, {}, {
144
178
  $type: Record<string, string>;
145
179
  }>;
180
+ billingAddress: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
181
+ name: "billing_address";
182
+ tableName: "shop_orders";
183
+ dataType: "json";
184
+ columnType: "PgJsonb";
185
+ data: Record<string, string>;
186
+ driverParam: unknown;
187
+ notNull: false;
188
+ hasDefault: false;
189
+ isPrimaryKey: false;
190
+ isAutoincrement: false;
191
+ hasRuntimeDefault: false;
192
+ enumValues: undefined;
193
+ baseColumn: never;
194
+ identity: undefined;
195
+ generated: undefined;
196
+ }, {}, {
197
+ $type: Record<string, string>;
198
+ }>;
199
+ invoiceRequested: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
200
+ name: "invoice_requested";
201
+ tableName: "shop_orders";
202
+ dataType: "boolean";
203
+ columnType: "PgBoolean";
204
+ data: boolean;
205
+ driverParam: boolean;
206
+ notNull: true;
207
+ hasDefault: true;
208
+ isPrimaryKey: false;
209
+ isAutoincrement: false;
210
+ hasRuntimeDefault: false;
211
+ enumValues: undefined;
212
+ baseColumn: never;
213
+ identity: undefined;
214
+ generated: undefined;
215
+ }, {}, {}>;
146
216
  totalNet: import("drizzle-orm/pg-core", { with: { "resolution-mode": "require" } }).PgColumn<{
147
217
  name: "total_net";
148
218
  tableName: "shop_orders";
@@ -8,7 +8,11 @@ export const shopOrdersTable = pgTable('shop_orders', {
8
8
  customerEmail: text('customer_email').notNull(),
9
9
  customerName: text('customer_name'),
10
10
  customerPhone: text('customer_phone'),
11
+ customerNip: text('customer_nip'),
12
+ customerCompanyName: text('customer_company_name'),
11
13
  shippingAddress: jsonb('shipping_address').$type(),
14
+ billingAddress: jsonb('billing_address').$type(),
15
+ invoiceRequested: boolean('invoice_requested').default(false).notNull(),
12
16
  totalNet: integer('total_net').notNull(),
13
17
  totalGross: integer('total_gross').notNull(),
14
18
  vatAmount: integer('vat_amount').notNull(),
@@ -0,0 +1,28 @@
1
+ import type { FakturowniaInvoiceBody } from './payload.js';
2
+ export interface FakturowniaClientOptions {
3
+ /** Account subdomain (`acme`) or full host/URL (`acme.fakturownia.pl`) — normalised either way. */
4
+ domain: string;
5
+ apiToken: string;
6
+ fetch?: typeof fetch;
7
+ }
8
+ export interface FakturowniaInvoice {
9
+ id: number | string;
10
+ number?: string;
11
+ view_url?: string;
12
+ [key: string]: unknown;
13
+ }
14
+ export declare class FakturowniaApiError extends Error {
15
+ readonly status: number;
16
+ readonly body?: unknown | undefined;
17
+ constructor(message: string, status: number, body?: unknown | undefined);
18
+ }
19
+ export declare class FakturowniaClient {
20
+ private readonly base;
21
+ private readonly apiToken;
22
+ private readonly fetchFn;
23
+ constructor(opts: FakturowniaClientOptions);
24
+ createInvoice(invoice: FakturowniaInvoiceBody): Promise<FakturowniaInvoice>;
25
+ sendByEmail(id: number | string): Promise<void>;
26
+ private postRaw;
27
+ private post;
28
+ }
@@ -0,0 +1,67 @@
1
+ export class FakturowniaApiError extends Error {
2
+ status;
3
+ body;
4
+ constructor(message, status, body) {
5
+ super(message);
6
+ this.status = status;
7
+ this.body = body;
8
+ this.name = 'FakturowniaApiError';
9
+ }
10
+ }
11
+ export class FakturowniaClient {
12
+ base;
13
+ apiToken;
14
+ fetchFn;
15
+ constructor(opts) {
16
+ if (!opts.domain)
17
+ throw new Error('FakturowniaClient: domain required.');
18
+ if (!opts.apiToken)
19
+ throw new Error('FakturowniaClient: apiToken required.');
20
+ // Accept a bare subdomain (`acme`) as well as a full host or URL
21
+ // (`acme.fakturownia.pl`, `https://acme.fakturownia.pl/`) — strip protocol,
22
+ // the `.fakturownia.pl` suffix and trailing slashes so we never double it.
23
+ const subdomain = opts.domain
24
+ .trim()
25
+ .replace(/^https?:\/\//i, '')
26
+ .replace(/\/+$/, '')
27
+ .replace(/\.fakturownia\.pl$/i, '');
28
+ this.base = `https://${subdomain}.fakturownia.pl`;
29
+ this.apiToken = opts.apiToken;
30
+ this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
31
+ }
32
+ async createInvoice(invoice) {
33
+ return this.post('/invoices.json', {
34
+ api_token: this.apiToken,
35
+ invoice
36
+ });
37
+ }
38
+ async sendByEmail(id) {
39
+ await this.postRaw(`/invoices/${encodeURIComponent(String(id))}/send_by_email.json`, {
40
+ api_token: this.apiToken
41
+ });
42
+ }
43
+ async postRaw(path, payload) {
44
+ const res = await this.fetchFn(`${this.base}${path}`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
47
+ body: JSON.stringify(payload)
48
+ });
49
+ if (!res.ok) {
50
+ let body;
51
+ try {
52
+ body = await res.json();
53
+ }
54
+ catch {
55
+ body = await res.text().catch(() => undefined);
56
+ }
57
+ const raw = body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body);
58
+ const detail = raw ? `: ${raw.slice(0, 400)}` : '';
59
+ throw new FakturowniaApiError(`Fakturownia ${path} → ${res.status}${detail}`, res.status, body);
60
+ }
61
+ return res;
62
+ }
63
+ async post(path, payload) {
64
+ const res = await this.postRaw(path, payload);
65
+ return (await res.json());
66
+ }
67
+ }
@@ -0,0 +1,27 @@
1
+ import type { InvoiceIssuePolicy, InvoicingAdapter } from '../../types.js';
2
+ export interface FakturowniaAdapterOptions {
3
+ /** Account subdomain (`acme`) or full host (`acme.fakturownia.pl`). Required. */
4
+ domain: string;
5
+ /** Fakturownia API token. Required. Pass from `$env/dynamic/private`. */
6
+ apiToken: string;
7
+ /** Adapter id. Default `fakturownia`. */
8
+ id?: string;
9
+ /** Invoice kind sent to Fakturownia. Default `vat`. */
10
+ kind?: string;
11
+ /** Unit of measure per line — KSeF list: szt, godz, dni, mc, m2, kg. Default `szt`. */
12
+ unit?: string;
13
+ /** When to issue automatically. Default (server-side) `b2bAndOnRequest`. */
14
+ issueWhen?: InvoiceIssuePolicy;
15
+ /** E-mail the invoice to the buyer via Fakturownia. Default `true`. */
16
+ sendEmail?: boolean;
17
+ /** Override fetch — primarily for testing. */
18
+ fetch?: typeof fetch;
19
+ }
20
+ /**
21
+ * Invoicing adapter backed by Fakturownia (fakturownia.pl). Issues a paid VAT
22
+ * invoice from a fully-paid order and (by default) e-mails the PDF to the buyer
23
+ * provider-side. Seller data and numbering are managed in the Fakturownia
24
+ * account; the NIP is validated upstream at checkout.
25
+ * @public
26
+ */
27
+ export declare function fakturowniaAdapter(opts: FakturowniaAdapterOptions): InvoicingAdapter;
@@ -0,0 +1,36 @@
1
+ import { FakturowniaClient } from './client.js';
2
+ import { buildFakturowniaInvoice } from './payload.js';
3
+ /**
4
+ * Invoicing adapter backed by Fakturownia (fakturownia.pl). Issues a paid VAT
5
+ * invoice from a fully-paid order and (by default) e-mails the PDF to the buyer
6
+ * provider-side. Seller data and numbering are managed in the Fakturownia
7
+ * account; the NIP is validated upstream at checkout.
8
+ * @public
9
+ */
10
+ export function fakturowniaAdapter(opts) {
11
+ const client = new FakturowniaClient({
12
+ domain: opts.domain,
13
+ apiToken: opts.apiToken,
14
+ fetch: opts.fetch
15
+ });
16
+ const adapter = {
17
+ id: opts.id ?? 'fakturownia',
18
+ issueWhen: opts.issueWhen,
19
+ async createInvoice(payload) {
20
+ const body = buildFakturowniaInvoice(payload, { kind: opts.kind, unit: opts.unit });
21
+ const inv = await client.createInvoice(body);
22
+ return {
23
+ externalId: String(inv.id),
24
+ number: inv.number,
25
+ pdfUrl: inv.view_url,
26
+ raw: inv
27
+ };
28
+ }
29
+ };
30
+ if (opts.sendEmail !== false) {
31
+ adapter.send = async (externalId, _ctx) => {
32
+ await client.sendByEmail(externalId);
33
+ };
34
+ }
35
+ return adapter;
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { InvoicePayload } from '../../types.js';
2
+ export interface FakturowniaPosition {
3
+ name: string;
4
+ quantity: number;
5
+ /** Total gross price of the line (unit × quantity) in the major unit (PLN). */
6
+ total_price_gross: number;
7
+ /** VAT rate as a plain percentage (e.g. 23). */
8
+ tax: number;
9
+ /** Unit of measure — KSeF requires one of: szt, godz, dni, mc, m2, kg. */
10
+ quantity_unit: string;
11
+ }
12
+ export interface FakturowniaInvoiceBody {
13
+ kind: string;
14
+ status: 'paid';
15
+ issue_date: string;
16
+ sell_date: string;
17
+ paid_date: string;
18
+ currency: string;
19
+ buyer_name: string;
20
+ buyer_email: string;
21
+ buyer_tax_no?: string;
22
+ buyer_street?: string;
23
+ buyer_city?: string;
24
+ buyer_post_code?: string;
25
+ buyer_country?: string;
26
+ positions: FakturowniaPosition[];
27
+ }
28
+ /**
29
+ * Map an {@link InvoicePayload} onto the Fakturownia `invoice` object. Pure —
30
+ * no network. The NIP is assumed already validated upstream (checkout).
31
+ */
32
+ export declare function buildFakturowniaInvoice(payload: InvoicePayload, opts?: {
33
+ kind?: string;
34
+ unit?: string;
35
+ }): FakturowniaInvoiceBody;
@@ -0,0 +1,45 @@
1
+ /** Minor units (grosze) → major units (PLN) with 2-decimal precision. */
2
+ function toMajor(minor) {
3
+ return Math.round(minor) / 100;
4
+ }
5
+ function pick(addr, ...keys) {
6
+ if (!addr)
7
+ return undefined;
8
+ for (const k of keys) {
9
+ if (addr[k])
10
+ return addr[k];
11
+ }
12
+ return undefined;
13
+ }
14
+ /**
15
+ * Map an {@link InvoicePayload} onto the Fakturownia `invoice` object. Pure —
16
+ * no network. The NIP is assumed already validated upstream (checkout).
17
+ */
18
+ export function buildFakturowniaInvoice(payload, opts = {}) {
19
+ const date = payload.paidAt.slice(0, 10);
20
+ const { buyer } = payload;
21
+ return {
22
+ kind: opts.kind ?? 'vat',
23
+ status: 'paid',
24
+ issue_date: date,
25
+ sell_date: date,
26
+ paid_date: date,
27
+ currency: payload.currency,
28
+ buyer_name: buyer.companyName || buyer.name,
29
+ buyer_email: buyer.email,
30
+ ...(buyer.nip ? { buyer_tax_no: buyer.nip } : {}),
31
+ ...(pick(buyer.address, 'street') ? { buyer_street: pick(buyer.address, 'street') } : {}),
32
+ ...(pick(buyer.address, 'city') ? { buyer_city: pick(buyer.address, 'city') } : {}),
33
+ ...(pick(buyer.address, 'postCode', 'zip', 'postalCode')
34
+ ? { buyer_post_code: pick(buyer.address, 'postCode', 'zip', 'postalCode') }
35
+ : {}),
36
+ ...(pick(buyer.address, 'country') ? { buyer_country: pick(buyer.address, 'country') } : {}),
37
+ positions: payload.items.map((item) => ({
38
+ name: item.name,
39
+ quantity: item.quantity,
40
+ total_price_gross: toMajor(item.unitPriceGross * item.quantity),
41
+ tax: item.vatRate,
42
+ quantity_unit: opts.unit ?? 'szt'
43
+ }))
44
+ };
45
+ }
@@ -39,7 +39,14 @@ export interface CheckoutInput {
39
39
  customerEmail: string;
40
40
  customerName?: string;
41
41
  customerPhone?: string;
42
+ /** Optional tax id (NIP). Validated server-side; invalid → checkout 400. */
43
+ customerNip?: string;
44
+ customerCompanyName?: string;
42
45
  shippingAddress?: Record<string, string>;
46
+ /** Separate billing address for the invoice; falls back to shipping. */
47
+ billingAddress?: Record<string, string>;
48
+ /** B2C opt-in: request an invoice even without a NIP. */
49
+ invoiceRequested?: boolean;
43
50
  shippingMethodId: string;
44
51
  carrierRef?: string;
45
52
  paymentMethod: string;
@@ -8,6 +8,7 @@ import { insertPaymentRow } from '../server/payments.js';
8
8
  import { getShippingMethod } from '../server/shipping.js';
9
9
  import { checkRateLimit, clientKey } from '../rate-limit.js';
10
10
  import { requireShopConfig } from '../server/db.js';
11
+ import { isValidNip } from '../nip.js';
11
12
  function shopEnabled() {
12
13
  try {
13
14
  return getCMS().shopConfig !== null;
@@ -66,6 +67,12 @@ export function createCheckoutHandler() {
66
67
  return json({ error: 'shippingMethodId required' }, { status: 400 });
67
68
  if (!paymentMethod)
68
69
  return json({ error: 'paymentMethod required' }, { status: 400 });
70
+ // Validate the NIP up front — Fakturownia rejects an invalid one and the
71
+ // invoice later goes to KSeF, so we never persist a malformed tax id.
72
+ const customerNip = asString(body.customerNip, 20);
73
+ if (customerNip && !isValidNip(customerNip)) {
74
+ return json({ error: 'Podany NIP jest nieprawidłowy.' }, { status: 400 });
75
+ }
69
76
  const cartItems = readCartCookie(cookies);
70
77
  if (cartItems.length === 0) {
71
78
  return json({ error: 'Cart is empty' }, { status: 400 });
@@ -98,7 +105,11 @@ export function createCheckoutHandler() {
98
105
  customerEmail,
99
106
  customerName: asString(body.customerName, 200),
100
107
  customerPhone: asString(body.customerPhone, 40),
108
+ customerNip,
109
+ customerCompanyName: asString(body.customerCompanyName, 200),
101
110
  shippingAddress: asStringRecord(body.shippingAddress),
111
+ billingAddress: asStringRecord(body.billingAddress),
112
+ invoiceRequested: body.invoiceRequested === true,
102
113
  shippingMethodId,
103
114
  carrierRef,
104
115
  paymentMethod,
@@ -9,5 +9,8 @@ export { stripeAdapter } from './adapters/stripe/index.js';
9
9
  export type { StripeAdapterOptions } from './adapters/stripe/index.js';
10
10
  export { inpostAdapter } from './adapters/inpost/index.js';
11
11
  export type { InpostAdapterOptions, InpostSenderAddress, GeowidgetConfigPreset, InpostEnvironment } from './adapters/inpost/index.js';
12
- export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText, VariantAttribute, VariantAttributeText, VariantAttributeNumber, VariantAttributeDatetime, VariantAttributeSelect, VariantAttributeBoolean, VariantAttributeImage, VariantAttributeEntry, VariantAttributeSlug, VariantLabelConfig, VariantExpiryConfig, PaymentPolicy, DepositAmount, PartialPayment } from './types.js';
12
+ export { fakturowniaAdapter } from './adapters/fakturownia/index.js';
13
+ export type { FakturowniaAdapterOptions } from './adapters/fakturownia/index.js';
14
+ export { isValidNip } from './nip.js';
15
+ export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText, VariantAttribute, VariantAttributeText, VariantAttributeNumber, VariantAttributeDatetime, VariantAttributeSelect, VariantAttributeBoolean, VariantAttributeImage, VariantAttributeEntry, VariantAttributeSlug, VariantLabelConfig, VariantExpiryConfig, PaymentPolicy, DepositAmount, PartialPayment, InvoicingAdapter, InvoiceIssuePolicy, InvoiceBuyer, InvoiceLineItem, InvoicePayload, InvoiceCreateResult, InvoiceContext } from './types.js';
13
16
  export { interpolateTemplate } from './template.js';
@@ -12,6 +12,7 @@ export function defineShop(config) {
12
12
  webhook: config.rateLimit?.webhook ?? { limit: 60, windowSec: 60 }
13
13
  },
14
14
  carriers: config.carriers ?? [],
15
+ invoicing: config.invoicing ?? null,
15
16
  consents: config.consents ?? [],
16
17
  orderViewUrl: config.orderViewUrl ?? '/shop/order/{orderNumber}?token={accessToken}',
17
18
  variantAttributes: config.variantAttributes ?? {},
@@ -25,4 +26,6 @@ export { manualAdapter } from './adapters/manual/index.js';
25
26
  export { payuAdapter } from './adapters/payu/index.js';
26
27
  export { stripeAdapter } from './adapters/stripe/index.js';
27
28
  export { inpostAdapter } from './adapters/inpost/index.js';
29
+ export { fakturowniaAdapter } from './adapters/fakturownia/index.js';
30
+ export { isValidNip } from './nip.js';
28
31
  export { interpolateTemplate } from './template.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validate a Polish NIP (tax id) — 10 digits with a weighted checksum.
3
+ *
4
+ * Accepts dashes, spaces and an optional `PL` prefix; these are stripped before
5
+ * validation. Returns `false` for any malformed input rather than throwing.
6
+ *
7
+ * Validation is intentionally strict: an invoice issued with an invalid NIP is
8
+ * rejected by Fakturownia and would later fail KSeF submission, so the buyer's
9
+ * NIP is verified at checkout time before it ever reaches the adapter.
10
+ * @public
11
+ */
12
+ export declare function isValidNip(nip: string): boolean;
@@ -0,0 +1,23 @@
1
+ const NIP_WEIGHTS = [6, 5, 7, 2, 3, 4, 5, 6, 7];
2
+ /**
3
+ * Validate a Polish NIP (tax id) — 10 digits with a weighted checksum.
4
+ *
5
+ * Accepts dashes, spaces and an optional `PL` prefix; these are stripped before
6
+ * validation. Returns `false` for any malformed input rather than throwing.
7
+ *
8
+ * Validation is intentionally strict: an invoice issued with an invalid NIP is
9
+ * rejected by Fakturownia and would later fail KSeF submission, so the buyer's
10
+ * NIP is verified at checkout time before it ever reaches the adapter.
11
+ * @public
12
+ */
13
+ export function isValidNip(nip) {
14
+ const normalized = nip.replace(/^PL/i, '').replace(/[\s-]/g, '');
15
+ if (!/^\d{10}$/.test(normalized))
16
+ return false;
17
+ const digits = normalized.split('').map(Number);
18
+ const sum = NIP_WEIGHTS.reduce((acc, weight, i) => acc + weight * digits[i], 0);
19
+ const checksum = sum % 11;
20
+ if (checksum === 10)
21
+ return false;
22
+ return checksum === digits[9];
23
+ }
@@ -0,0 +1,64 @@
1
+ import { shopInvoicesTable, type ShopInvoiceStatus } from '../../db-postgres/schema/shop/index.js';
2
+ import type { Currency, InvoiceIssuePolicy, InvoicePayload, OrderStatus } from '../types.js';
3
+ type InvoiceRow = typeof shopInvoicesTable.$inferSelect;
4
+ /** Order fields the trigger / idempotency decision depends on. */
5
+ export interface InvoiceOrderState {
6
+ status: OrderStatus;
7
+ balanceOwed: boolean;
8
+ customerNip: string | null;
9
+ invoiceRequested: boolean;
10
+ }
11
+ /** Order fields needed to build the invoice payload. */
12
+ export interface InvoiceOrderData {
13
+ number: string;
14
+ currency: Currency;
15
+ customerEmail: string;
16
+ customerName: string | null;
17
+ customerNip: string | null;
18
+ customerCompanyName: string | null;
19
+ shippingAddress: Record<string, string> | null;
20
+ billingAddress: Record<string, string> | null;
21
+ language: string | null;
22
+ }
23
+ export interface InvoiceItemData {
24
+ nameSnapshot: Record<string, string>;
25
+ qty: number;
26
+ priceGrossSnapshot: number;
27
+ vatRate: number;
28
+ }
29
+ export type InvoiceAction = 'skip' | 'create' | 'resend';
30
+ export declare class InvoiceError extends Error {
31
+ readonly code: string;
32
+ constructor(code: string, message: string);
33
+ }
34
+ /** Does the order qualify for an automatic invoice under `policy`? Pure. */
35
+ export declare function shouldIssueInvoice(order: InvoiceOrderState, policy: InvoiceIssuePolicy): boolean;
36
+ /** Fully paid = a paid-or-later status with no outstanding balance. Pure. */
37
+ export declare function isOrderFullyPaid(order: InvoiceOrderState): boolean;
38
+ /**
39
+ * Decide what to do with an order's invoice given any existing record. Pure —
40
+ * encodes the trigger + idempotency rules so they can be tested without a DB.
41
+ */
42
+ export declare function decideInvoiceAction(order: InvoiceOrderState, existing: {
43
+ status: ShopInvoiceStatus;
44
+ } | null, policy: InvoiceIssuePolicy, opts?: {
45
+ force?: boolean;
46
+ }): InvoiceAction;
47
+ /** Map order + items onto the provider-agnostic {@link InvoicePayload}. Pure. */
48
+ export declare function buildInvoicePayload(order: InvoiceOrderData, items: InvoiceItemData[], paidAt: string): InvoicePayload;
49
+ export declare function getInvoiceByOrderId(orderId: string): Promise<InvoiceRow | null>;
50
+ /**
51
+ * Issue an invoice for an order if it qualifies. Fire-and-forget, fail-open —
52
+ * never throws, so a failing invoicing provider can't block the payment webhook.
53
+ * Called from `updateOrderStatus` (on `paid`) and `markBalancePaid`.
54
+ */
55
+ export declare function maybeIssueInvoiceForOrder(orderId: string): Promise<void>;
56
+ /**
57
+ * Issue (or retry/resend) an invoice on demand from the admin. Throws on error
58
+ * so the caller can surface it. `force` bypasses the trigger policy and allows
59
+ * re-sending an already-issued invoice.
60
+ */
61
+ export declare function issueInvoiceForOrder(orderId: string, opts?: {
62
+ force?: boolean;
63
+ }): Promise<InvoiceRow | null>;
64
+ export {};