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.
- package/API.md +19 -3
- package/CHANGELOG.md +40 -0
- package/DOCS.md +1 -1
- package/dist/admin/client/shop/shop-order-detail-page.svelte +85 -0
- package/dist/admin/remote/shop.remote.d.ts +58 -0
- package/dist/admin/remote/shop.remote.js +18 -0
- package/dist/db-postgres/schema/shop/index.d.ts +1 -0
- package/dist/db-postgres/schema/shop/index.js +1 -0
- package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
- package/dist/db-postgres/schema/shop/invoice.js +27 -0
- package/dist/db-postgres/schema/shop/order.d.ts +70 -0
- package/dist/db-postgres/schema/shop/order.js +4 -0
- package/dist/shop/adapters/fakturownia/client.d.ts +28 -0
- package/dist/shop/adapters/fakturownia/client.js +67 -0
- package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
- package/dist/shop/adapters/fakturownia/index.js +36 -0
- package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
- package/dist/shop/adapters/fakturownia/payload.js +45 -0
- package/dist/shop/client/index.d.ts +7 -0
- package/dist/shop/http/checkout-handler.js +11 -0
- package/dist/shop/index.d.ts +4 -1
- package/dist/shop/index.js +3 -0
- package/dist/shop/nip.d.ts +12 -0
- package/dist/shop/nip.js +23 -0
- package/dist/shop/server/invoices.d.ts +64 -0
- package/dist/shop/server/invoices.js +237 -0
- package/dist/shop/server/orders.d.ts +4 -0
- package/dist/shop/server/orders.js +11 -0
- package/dist/shop/types.d.ts +67 -1
- package/dist/updates/0.28.0/index.d.ts +2 -0
- package/dist/updates/0.28.0/index.js +38 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { shopInvoicesTable, shopOrderItemsTable, shopOrdersTable } from '../../db-postgres/schema/shop/index.js';
|
|
3
|
+
import { getShopDb, requireShopConfig } from './db.js';
|
|
4
|
+
const FULLY_PAID_STATES = new Set(['paid', 'preparing', 'sent', 'done']);
|
|
5
|
+
export class InvoiceError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = 'InvoiceError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Does the order qualify for an automatic invoice under `policy`? Pure. */
|
|
14
|
+
export function shouldIssueInvoice(order, policy) {
|
|
15
|
+
if (policy === 'always')
|
|
16
|
+
return true;
|
|
17
|
+
return Boolean(order.customerNip) || order.invoiceRequested;
|
|
18
|
+
}
|
|
19
|
+
/** Fully paid = a paid-or-later status with no outstanding balance. Pure. */
|
|
20
|
+
export function isOrderFullyPaid(order) {
|
|
21
|
+
return FULLY_PAID_STATES.has(order.status) && !order.balanceOwed;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Decide what to do with an order's invoice given any existing record. Pure —
|
|
25
|
+
* encodes the trigger + idempotency rules so they can be tested without a DB.
|
|
26
|
+
*/
|
|
27
|
+
export function decideInvoiceAction(order, existing, policy, opts = {}) {
|
|
28
|
+
if (!isOrderFullyPaid(order))
|
|
29
|
+
return 'skip';
|
|
30
|
+
const force = opts.force ?? false;
|
|
31
|
+
if (existing) {
|
|
32
|
+
switch (existing.status) {
|
|
33
|
+
case 'issued':
|
|
34
|
+
case 'sent':
|
|
35
|
+
return force ? 'resend' : 'skip';
|
|
36
|
+
case 'failed':
|
|
37
|
+
return 'create';
|
|
38
|
+
case 'pending':
|
|
39
|
+
return force ? 'create' : 'skip';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (force || shouldIssueInvoice(order, policy))
|
|
43
|
+
return 'create';
|
|
44
|
+
return 'skip';
|
|
45
|
+
}
|
|
46
|
+
// Order-item `nameSnapshot` is `{ product, variant }` (see createOrderFromCart).
|
|
47
|
+
// The invoice line shows both so the customer sees the exact session they paid
|
|
48
|
+
// for (e.g. "Odporność psychiczna — Poznań • 20 września 2026").
|
|
49
|
+
function lineName(name) {
|
|
50
|
+
const product = name.product ?? '';
|
|
51
|
+
const variant = name.variant ?? '';
|
|
52
|
+
if (product && variant)
|
|
53
|
+
return `${product} — ${variant}`;
|
|
54
|
+
return product || variant || Object.values(name)[0] || '';
|
|
55
|
+
}
|
|
56
|
+
/** Map order + items onto the provider-agnostic {@link InvoicePayload}. Pure. */
|
|
57
|
+
export function buildInvoicePayload(order, items, paidAt) {
|
|
58
|
+
return {
|
|
59
|
+
orderNumber: order.number,
|
|
60
|
+
currency: order.currency,
|
|
61
|
+
paidAt,
|
|
62
|
+
buyer: {
|
|
63
|
+
name: order.customerName || order.customerEmail,
|
|
64
|
+
email: order.customerEmail,
|
|
65
|
+
nip: order.customerNip,
|
|
66
|
+
companyName: order.customerCompanyName,
|
|
67
|
+
address: order.billingAddress ?? order.shippingAddress ?? null
|
|
68
|
+
},
|
|
69
|
+
items: items.map((item) => ({
|
|
70
|
+
name: lineName(item.nameSnapshot),
|
|
71
|
+
quantity: item.qty,
|
|
72
|
+
unitPriceGross: item.priceGrossSnapshot,
|
|
73
|
+
vatRate: item.vatRate
|
|
74
|
+
}))
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export async function getInvoiceByOrderId(orderId) {
|
|
78
|
+
const db = getShopDb();
|
|
79
|
+
const [row] = await db
|
|
80
|
+
.select()
|
|
81
|
+
.from(shopInvoicesTable)
|
|
82
|
+
.where(eq(shopInvoicesTable.orderId, orderId));
|
|
83
|
+
return row ?? null;
|
|
84
|
+
}
|
|
85
|
+
function toOrderData(order) {
|
|
86
|
+
return {
|
|
87
|
+
number: order.number,
|
|
88
|
+
currency: order.currency,
|
|
89
|
+
customerEmail: order.customerEmail,
|
|
90
|
+
customerName: order.customerName,
|
|
91
|
+
customerNip: order.customerNip,
|
|
92
|
+
customerCompanyName: order.customerCompanyName,
|
|
93
|
+
shippingAddress: order.shippingAddress ?? null,
|
|
94
|
+
billingAddress: order.billingAddress ?? null,
|
|
95
|
+
language: order.language
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function toItemData(items) {
|
|
99
|
+
return items.map((i) => ({
|
|
100
|
+
nameSnapshot: i.nameSnapshot,
|
|
101
|
+
qty: i.qty,
|
|
102
|
+
priceGrossSnapshot: i.priceGrossSnapshot,
|
|
103
|
+
vatRate: i.vatRate
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
async function runCreate(order, existing, adapter) {
|
|
107
|
+
const db = getShopDb();
|
|
108
|
+
const now = new Date();
|
|
109
|
+
let invoiceId;
|
|
110
|
+
if (existing) {
|
|
111
|
+
const [row] = await db
|
|
112
|
+
.update(shopInvoicesTable)
|
|
113
|
+
.set({
|
|
114
|
+
status: 'pending',
|
|
115
|
+
provider: adapter.id,
|
|
116
|
+
attempts: existing.attempts + 1,
|
|
117
|
+
lastError: null,
|
|
118
|
+
updatedAt: now
|
|
119
|
+
})
|
|
120
|
+
.where(eq(shopInvoicesTable.id, existing.id))
|
|
121
|
+
.returning();
|
|
122
|
+
invoiceId = row.id;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const [row] = await db
|
|
126
|
+
.insert(shopInvoicesTable)
|
|
127
|
+
.values({
|
|
128
|
+
orderId: order.id,
|
|
129
|
+
provider: adapter.id,
|
|
130
|
+
status: 'pending',
|
|
131
|
+
attempts: 1
|
|
132
|
+
})
|
|
133
|
+
.returning();
|
|
134
|
+
invoiceId = row.id;
|
|
135
|
+
}
|
|
136
|
+
const items = await db
|
|
137
|
+
.select()
|
|
138
|
+
.from(shopOrderItemsTable)
|
|
139
|
+
.where(eq(shopOrderItemsTable.orderId, order.id));
|
|
140
|
+
const paidAt = order.partialPayment?.paidAt ?? now.toISOString();
|
|
141
|
+
const payload = buildInvoicePayload(toOrderData(order), toItemData(items), paidAt);
|
|
142
|
+
try {
|
|
143
|
+
const result = await adapter.createInvoice(payload, { language: order.language });
|
|
144
|
+
let status = 'issued';
|
|
145
|
+
if (adapter.send) {
|
|
146
|
+
await adapter.send(result.externalId, { language: order.language });
|
|
147
|
+
status = 'sent';
|
|
148
|
+
}
|
|
149
|
+
const [updated] = await db
|
|
150
|
+
.update(shopInvoicesTable)
|
|
151
|
+
.set({
|
|
152
|
+
status,
|
|
153
|
+
externalId: result.externalId,
|
|
154
|
+
number: result.number ?? null,
|
|
155
|
+
pdfUrl: result.pdfUrl ?? null,
|
|
156
|
+
raw: result.raw ?? null,
|
|
157
|
+
lastError: null,
|
|
158
|
+
updatedAt: new Date()
|
|
159
|
+
})
|
|
160
|
+
.where(eq(shopInvoicesTable.id, invoiceId))
|
|
161
|
+
.returning();
|
|
162
|
+
return updated;
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
await db
|
|
166
|
+
.update(shopInvoicesTable)
|
|
167
|
+
.set({
|
|
168
|
+
status: 'failed',
|
|
169
|
+
lastError: err instanceof Error ? err.message : String(err),
|
|
170
|
+
updatedAt: new Date()
|
|
171
|
+
})
|
|
172
|
+
.where(eq(shopInvoicesTable.id, invoiceId));
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function runResend(order, existing, adapter) {
|
|
177
|
+
if (!existing.externalId)
|
|
178
|
+
throw new InvoiceError('not_issued', 'No issued invoice to resend');
|
|
179
|
+
if (!adapter.send)
|
|
180
|
+
throw new InvoiceError('send_unsupported', 'Adapter cannot e-mail invoices');
|
|
181
|
+
const db = getShopDb();
|
|
182
|
+
await adapter.send(existing.externalId, { language: order.language });
|
|
183
|
+
const [updated] = await db
|
|
184
|
+
.update(shopInvoicesTable)
|
|
185
|
+
.set({ status: 'sent', updatedAt: new Date() })
|
|
186
|
+
.where(eq(shopInvoicesTable.id, existing.id))
|
|
187
|
+
.returning();
|
|
188
|
+
return updated;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Issue an invoice for an order if it qualifies. Fire-and-forget, fail-open —
|
|
192
|
+
* never throws, so a failing invoicing provider can't block the payment webhook.
|
|
193
|
+
* Called from `updateOrderStatus` (on `paid`) and `markBalancePaid`.
|
|
194
|
+
*/
|
|
195
|
+
export async function maybeIssueInvoiceForOrder(orderId) {
|
|
196
|
+
try {
|
|
197
|
+
const adapter = requireShopConfig().invoicing;
|
|
198
|
+
if (!adapter)
|
|
199
|
+
return;
|
|
200
|
+
const db = getShopDb();
|
|
201
|
+
const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
202
|
+
if (!order)
|
|
203
|
+
return;
|
|
204
|
+
const existing = await getInvoiceByOrderId(orderId);
|
|
205
|
+
const policy = adapter.issueWhen ?? 'b2bAndOnRequest';
|
|
206
|
+
const action = decideInvoiceAction(order, existing, policy);
|
|
207
|
+
if (action === 'create')
|
|
208
|
+
await runCreate(order, existing, adapter);
|
|
209
|
+
else if (action === 'resend' && existing)
|
|
210
|
+
await runResend(order, existing, adapter);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
console.error(`[shop] invoice issuance failed for order ${orderId}:`, err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Issue (or retry/resend) an invoice on demand from the admin. Throws on error
|
|
218
|
+
* so the caller can surface it. `force` bypasses the trigger policy and allows
|
|
219
|
+
* re-sending an already-issued invoice.
|
|
220
|
+
*/
|
|
221
|
+
export async function issueInvoiceForOrder(orderId, opts = {}) {
|
|
222
|
+
const adapter = requireShopConfig().invoicing;
|
|
223
|
+
if (!adapter)
|
|
224
|
+
throw new InvoiceError('no_adapter', 'No invoicing adapter configured');
|
|
225
|
+
const db = getShopDb();
|
|
226
|
+
const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
227
|
+
if (!order)
|
|
228
|
+
throw new InvoiceError('order_not_found', 'Order not found');
|
|
229
|
+
const existing = await getInvoiceByOrderId(orderId);
|
|
230
|
+
const policy = adapter.issueWhen ?? 'b2bAndOnRequest';
|
|
231
|
+
const action = decideInvoiceAction(order, existing, policy, { force: opts.force ?? false });
|
|
232
|
+
if (action === 'create')
|
|
233
|
+
return runCreate(order, existing, adapter);
|
|
234
|
+
if (action === 'resend' && existing)
|
|
235
|
+
return runResend(order, existing, adapter);
|
|
236
|
+
return existing;
|
|
237
|
+
}
|
|
@@ -25,7 +25,11 @@ export interface CreateOrderInput {
|
|
|
25
25
|
customerEmail: string;
|
|
26
26
|
customerName?: string;
|
|
27
27
|
customerPhone?: string;
|
|
28
|
+
customerNip?: string;
|
|
29
|
+
customerCompanyName?: string;
|
|
28
30
|
shippingAddress?: Record<string, string>;
|
|
31
|
+
billingAddress?: Record<string, string>;
|
|
32
|
+
invoiceRequested?: boolean;
|
|
29
33
|
shippingMethodId: string;
|
|
30
34
|
carrierRef?: string;
|
|
31
35
|
paymentMethod: string;
|
|
@@ -9,6 +9,7 @@ import { sendLowStockEmail, sendOrderStatusEmail } from './email.js';
|
|
|
9
9
|
import { isPaymentMethodAllowed } from './payment-compat.js';
|
|
10
10
|
import { isVariantExpired, VariantExpiredError } from '../expiry.js';
|
|
11
11
|
import { resolvePaymentAmount } from './payment-policy.js';
|
|
12
|
+
import { maybeIssueInvoiceForOrder } from './invoices.js';
|
|
12
13
|
import { CouponError, recordCouponRedemption, releaseCouponSlot, reserveCouponSlot, validateCoupon } from './coupons.js';
|
|
13
14
|
/**
|
|
14
15
|
* @public
|
|
@@ -237,7 +238,11 @@ export async function createOrderFromCart(input) {
|
|
|
237
238
|
customerEmail: input.customerEmail,
|
|
238
239
|
customerName: input.customerName ?? null,
|
|
239
240
|
customerPhone: input.customerPhone ?? null,
|
|
241
|
+
customerNip: input.customerNip ?? null,
|
|
242
|
+
customerCompanyName: input.customerCompanyName ?? null,
|
|
240
243
|
shippingAddress: input.shippingAddress ?? null,
|
|
244
|
+
billingAddress: input.billingAddress ?? null,
|
|
245
|
+
invoiceRequested: input.invoiceRequested ?? false,
|
|
241
246
|
totalNet,
|
|
242
247
|
totalGross,
|
|
243
248
|
vatAmount: totalVat,
|
|
@@ -430,6 +435,10 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
|
|
|
430
435
|
}
|
|
431
436
|
const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
432
437
|
void sendOrderStatusEmail(orderId, status);
|
|
438
|
+
// Auto-issue an invoice once the order is paid. Fail-open / fire-and-forget —
|
|
439
|
+
// the guard inside skips deposit orders that still owe a balance.
|
|
440
|
+
if (status === 'paid')
|
|
441
|
+
void maybeIssueInvoiceForOrder(orderId);
|
|
433
442
|
return updated;
|
|
434
443
|
}
|
|
435
444
|
/**
|
|
@@ -461,6 +470,8 @@ export async function markBalancePaid(orderId) {
|
|
|
461
470
|
})
|
|
462
471
|
.where(eq(shopOrdersTable.id, orderId));
|
|
463
472
|
const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
473
|
+
// Balance cleared → the order is now fully paid; issue its invoice.
|
|
474
|
+
void maybeIssueInvoiceForOrder(orderId);
|
|
464
475
|
return updated ?? null;
|
|
465
476
|
}
|
|
466
477
|
export async function setPaymentProviderRef(orderId, ref) {
|
package/dist/shop/types.d.ts
CHANGED
|
@@ -265,11 +265,76 @@ export interface VariantExpiryConfig {
|
|
|
265
265
|
source: string;
|
|
266
266
|
offsetDays: number;
|
|
267
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* When an invoice should be issued automatically after an order is fully paid.
|
|
270
|
+
* - `b2bAndOnRequest` (default): only when the order carries a NIP (B2B) or the
|
|
271
|
+
* customer explicitly requested an invoice (`invoiceRequested`).
|
|
272
|
+
* - `always`: for every fully-paid order regardless of NIP / request.
|
|
273
|
+
* @public
|
|
274
|
+
*/
|
|
275
|
+
export type InvoiceIssuePolicy = 'b2bAndOnRequest' | 'always';
|
|
276
|
+
/** @public */
|
|
277
|
+
export interface InvoiceBuyer {
|
|
278
|
+
name: string;
|
|
279
|
+
email: string;
|
|
280
|
+
nip?: string | null;
|
|
281
|
+
companyName?: string | null;
|
|
282
|
+
address?: Record<string, string> | null;
|
|
283
|
+
}
|
|
284
|
+
/** @public */
|
|
285
|
+
export interface InvoiceLineItem {
|
|
286
|
+
name: string;
|
|
287
|
+
quantity: number;
|
|
288
|
+
/** Gross unit price in minor units (grosze for PLN). */
|
|
289
|
+
unitPriceGross: number;
|
|
290
|
+
/** VAT rate as a plain percentage (e.g. `23` for 23%). */
|
|
291
|
+
vatRate: number;
|
|
292
|
+
}
|
|
293
|
+
/** @public */
|
|
294
|
+
export interface InvoicePayload {
|
|
295
|
+
orderNumber: string;
|
|
296
|
+
currency: Currency;
|
|
297
|
+
buyer: InvoiceBuyer;
|
|
298
|
+
items: InvoiceLineItem[];
|
|
299
|
+
/** ISO timestamp the payment was confirmed — used as issue / paid date. */
|
|
300
|
+
paidAt: string;
|
|
301
|
+
}
|
|
302
|
+
/** @public */
|
|
303
|
+
export interface InvoiceCreateResult {
|
|
304
|
+
externalId: string;
|
|
305
|
+
number?: string;
|
|
306
|
+
pdfUrl?: string;
|
|
307
|
+
raw?: unknown;
|
|
308
|
+
}
|
|
309
|
+
/** @public */
|
|
310
|
+
export interface InvoiceContext {
|
|
311
|
+
language?: string | null;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Adapter that issues invoices with an external provider (e.g. Fakturownia).
|
|
315
|
+
* Registered via `defineShop({ invoicing })`. `createInvoice` is called once an
|
|
316
|
+
* order is fully paid and qualifies under `issueWhen`; `send` (optional) e-mails
|
|
317
|
+
* the issued invoice to the buyer provider-side.
|
|
318
|
+
* @public
|
|
319
|
+
*/
|
|
320
|
+
export interface InvoicingAdapter {
|
|
321
|
+
id: string;
|
|
322
|
+
/** Trigger policy. Default `b2bAndOnRequest`. */
|
|
323
|
+
issueWhen?: InvoiceIssuePolicy;
|
|
324
|
+
createInvoice(payload: InvoicePayload, ctx?: InvoiceContext): Promise<InvoiceCreateResult>;
|
|
325
|
+
send?(externalId: string, ctx?: InvoiceContext): Promise<void>;
|
|
326
|
+
}
|
|
268
327
|
export interface ShopConfig {
|
|
269
328
|
currency: Currency;
|
|
270
329
|
vatRates: number[];
|
|
271
330
|
payment: PaymentAdapter[];
|
|
272
331
|
carriers?: CarrierAdapter[];
|
|
332
|
+
/**
|
|
333
|
+
* Optional invoicing adapter — issues invoices automatically once an order is
|
|
334
|
+
* fully paid. Omitted = no invoicing.
|
|
335
|
+
* @public
|
|
336
|
+
*/
|
|
337
|
+
invoicing?: InvoicingAdapter | null;
|
|
273
338
|
features?: ShopFeatures;
|
|
274
339
|
rateLimit?: ShopRateLimit;
|
|
275
340
|
consents?: ConsentConfig[];
|
|
@@ -306,11 +371,12 @@ export interface ShopConfig {
|
|
|
306
371
|
*/
|
|
307
372
|
variantExpiry?: VariantExpiryConfig;
|
|
308
373
|
}
|
|
309
|
-
export interface ResolvedShopConfig extends Omit<ShopConfig, 'variantLabel' | 'variantExpiry'> {
|
|
374
|
+
export interface ResolvedShopConfig extends Omit<ShopConfig, 'variantLabel' | 'variantExpiry' | 'invoicing'> {
|
|
310
375
|
features: Required<ShopFeatures>;
|
|
311
376
|
rateLimit: Required<ShopRateLimit>;
|
|
312
377
|
carriers: CarrierAdapter[];
|
|
313
378
|
consents: ConsentConfig[];
|
|
379
|
+
invoicing: InvoicingAdapter | null;
|
|
314
380
|
orderViewUrl: string;
|
|
315
381
|
variantAttributes: Record<string, VariantAttribute>;
|
|
316
382
|
variantLabel: VariantLabelConfig | null;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.28.0',
|
|
3
|
+
date: '2026-06-01',
|
|
4
|
+
description: 'Fakturowanie — integracja sklepu z Fakturownią: automatyczne wystawianie i wysyłka faktury po opłaceniu zamówienia. Nowy generyczny `InvoicingAdapter` (spójny z payment/carrier) + `fakturowniaAdapter()` jako pierwsza implementacja. Faktura wystawiana fire-and-forget gdy zamówienie jest w pełni opłacone (`!balanceOwed` — zaliczki czekają na dopłatę balansu), wysyłana przez Fakturownię (`send_by_email`). Trigger konfigurowalny per-adapter (`issueWhen`: `b2bAndOnRequest` domyślnie / `always`). Checkout zbiera dane B2B (NIP z twardą walidacją sumy kontrolnej, nazwa firmy, adres do faktury, opt-in „chcę fakturę"). Idempotencja przez `shop_invoices` (unikat per zamówienie); ręczny retry w adminie. Additive only — żadnych breaking zmian.',
|
|
5
|
+
features: [
|
|
6
|
+
'`defineShop({ invoicing })` (`includio-cms/shop`) — nowy opcjonalny slot adaptera fakturowania, analogiczny do `payment` / `carriers`. `InvoicingAdapter { id, issueWhen?, createInvoice(payload, ctx), send?(externalId, ctx) }` + typy payloadu (`InvoicePayload`, `InvoiceBuyer`, `InvoiceLineItem`, `InvoiceCreateResult`, `InvoiceContext`, `InvoiceIssuePolicy`) wyeksportowane jako `@public` z `includio-cms/shop`.',
|
|
7
|
+
'`fakturowniaAdapter({ domain, apiToken, kind?, issueWhen?, sendEmail? })` (`includio-cms/shop`, `@public`) — adapter dla Fakturowni (fakturownia.pl). `createInvoice` mapuje zamówienie na fakturę VAT oznaczoną jako opłacona (`paid_date` = data potwierdzenia płatności), `send` woła `send_by_email` (domyślnie włączone). Numeracja i dane sprzedawcy zarządzane po stronie konta Fakturowni. Sekret `apiToken` przekazywany przez konsumenta z `$env/dynamic/private` (jak `STRIPE_SECRET_KEY`).',
|
|
8
|
+
'Auto-faktura po opłaceniu — `maybeIssueInvoiceForOrder(orderId)` (`$lib/shop/server/invoices.ts`) wołane fire-and-forget z `updateOrderStatus` (przy `paid`) oraz z `markBalancePaid` (po dopłacie balansu). Fail-open: błąd providera nie blokuje webhooka płatności. Guard wystawia fakturę tylko gdy zamówienie w pełni opłacone (`status ∈ {paid, preparing, sent, done}` && `!balanceOwed`) — deposit czeka na dopłatę.',
|
|
9
|
+
'Konfigurowalny trigger `issueWhen`: `b2bAndOnRequest` (domyślny — faktura gdy podano NIP lub zaznaczono „chcę fakturę") albo `always` (każde opłacone zamówienie, np. dla klienta wymagającego faktur również dla osób fizycznych).',
|
|
10
|
+
'`shop_invoices` (nowa tabela) — jedna faktura per zamówienie (`order_id` UNIQUE → idempotencja). Pola `status` (`pending`/`issued`/`sent`/`failed`), `external_id`, `number`, `pdf_url`, `attempts`, `next_retry_at`, `last_error`, `raw`. Kolumny `attempts`/`next_retry_at` zarezerwowane pod przyszły automatyczny retry (obecnie tylko ręczny).',
|
|
11
|
+
'Checkout B2B — `shop_orders` dostaje kolumny `customer_nip`, `customer_company_name`, `billing_address` (jsonb), `invoice_requested` (boolean). `createOrderFromCart` / `checkout-handler` przyjmują i zapisują te pola; `CheckoutInput` (`includio-cms/shop/client`) rozszerzony. Storefront form (pola NIP/firma/checkbox/adres) = warstwa konsumenta.',
|
|
12
|
+
'`isValidNip(nip)` (`includio-cms/shop`, `@public`) — twarda walidacja polskiego NIP (10 cyfr + suma kontrolna, wagi `[6,5,7,2,3,4,5,6,7]`). Checkout odrzuca nieprawidłowy NIP (400) zanim trafi do bazy — Fakturownia odrzuciłaby błędny NIP, a faktura idzie dalej do KSeF, więc walidujemy u źródła.',
|
|
13
|
+
'`getOrderInvoiceAdmin` (query) + `issueInvoiceCmd` (command) — admin remote functions (`includio-cms/admin/remote`). `shop-order-detail-page.svelte` zyskuje sekcję „Faktura": numer, link do PDF, status, przycisk „Wystaw fakturę" / „Wystaw ponownie" (ręczny retry, `force`). Auth-protected (`requireAuth`).'
|
|
14
|
+
],
|
|
15
|
+
fixes: [],
|
|
16
|
+
breakingChanges: [],
|
|
17
|
+
sql: `CREATE TABLE IF NOT EXISTS shop_invoices (
|
|
18
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
19
|
+
order_id uuid NOT NULL UNIQUE REFERENCES shop_orders(id) ON DELETE CASCADE,
|
|
20
|
+
provider text NOT NULL,
|
|
21
|
+
external_id text,
|
|
22
|
+
number text,
|
|
23
|
+
pdf_url text,
|
|
24
|
+
kind text NOT NULL DEFAULT 'vat',
|
|
25
|
+
status text NOT NULL DEFAULT 'pending',
|
|
26
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
27
|
+
next_retry_at timestamptz,
|
|
28
|
+
last_error text,
|
|
29
|
+
raw jsonb,
|
|
30
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
31
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
ALTER TABLE shop_orders ADD COLUMN IF NOT EXISTS customer_nip text;
|
|
35
|
+
ALTER TABLE shop_orders ADD COLUMN IF NOT EXISTS customer_company_name text;
|
|
36
|
+
ALTER TABLE shop_orders ADD COLUMN IF NOT EXISTS billing_address jsonb;
|
|
37
|
+
ALTER TABLE shop_orders ADD COLUMN IF NOT EXISTS invoice_requested boolean NOT NULL DEFAULT false;`
|
|
38
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -62,6 +62,7 @@ import { update as update0250 } from './0.25.0/index.js';
|
|
|
62
62
|
import { update as update0260 } from './0.26.0/index.js';
|
|
63
63
|
import { update as update0261 } from './0.26.1/index.js';
|
|
64
64
|
import { update as update0270 } from './0.27.0/index.js';
|
|
65
|
+
import { update as update0280 } from './0.28.0/index.js';
|
|
65
66
|
export const updates = [
|
|
66
67
|
update0065,
|
|
67
68
|
update0066,
|
|
@@ -126,7 +127,8 @@ export const updates = [
|
|
|
126
127
|
update0250,
|
|
127
128
|
update0260,
|
|
128
129
|
update0261,
|
|
129
|
-
update0270
|
|
130
|
+
update0270,
|
|
131
|
+
update0280
|
|
130
132
|
];
|
|
131
133
|
export const getUpdatesFrom = (fromVersion) => {
|
|
132
134
|
const fromParts = fromVersion.split('.').map(Number);
|