includio-cms 0.27.0 → 0.33.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 (115) hide show
  1. package/API.md +58 -14
  2. package/CHANGELOG.md +59 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +1 -0
  5. package/dist/admin/api/handler.js +4 -0
  6. package/dist/admin/api/integrations.d.ts +13 -0
  7. package/dist/admin/api/integrations.js +61 -0
  8. package/dist/admin/api/test-email.d.ts +9 -0
  9. package/dist/admin/api/test-email.js +39 -0
  10. package/dist/admin/auth-client.d.ts +543 -543
  11. package/dist/admin/client/index.d.ts +10 -0
  12. package/dist/admin/client/index.js +12 -0
  13. package/dist/admin/client/maintenance/maintenance-page.svelte +210 -0
  14. package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
  15. package/dist/admin/client/shop/restore-order-cell.svelte +29 -0
  16. package/dist/admin/client/shop/restore-order-cell.svelte.d.ts +8 -0
  17. package/dist/admin/client/shop/shop-order-detail-page.svelte +156 -1
  18. package/dist/admin/client/shop/shop-orders-list-page.svelte +113 -53
  19. package/dist/admin/components/layout/app-sidebar.svelte +2 -0
  20. package/dist/admin/components/layout/nav-custom.svelte +26 -0
  21. package/dist/admin/components/layout/nav-custom.svelte.d.ts +3 -0
  22. package/dist/admin/components/layout/page-header.svelte +13 -3
  23. package/dist/admin/components/layout/page-header.svelte.d.ts +13 -3
  24. package/dist/admin/remote/admin.remote.d.ts +7 -0
  25. package/dist/admin/remote/admin.remote.js +10 -0
  26. package/dist/admin/remote/entry.remote.d.ts +2 -2
  27. package/dist/admin/remote/index.d.ts +1 -0
  28. package/dist/admin/remote/index.js +1 -0
  29. package/dist/admin/remote/invite.d.ts +1 -1
  30. package/dist/admin/remote/shop.remote.d.ts +125 -40
  31. package/dist/admin/remote/shop.remote.js +59 -10
  32. package/dist/admin/types.d.ts +15 -0
  33. package/dist/admin/utils/csv-export.d.ts +45 -0
  34. package/dist/admin/utils/csv-export.js +61 -0
  35. package/dist/cli/scaffold/admin.js +1 -1
  36. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  37. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  38. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  39. package/dist/core/cms.d.ts +44 -2
  40. package/dist/core/cms.js +64 -0
  41. package/dist/core/index.d.ts +2 -4
  42. package/dist/core/index.js +1 -4
  43. package/dist/core/server/index.d.ts +4 -1
  44. package/dist/core/server/index.js +4 -1
  45. package/dist/db-postgres/schema/shop/index.d.ts +1 -0
  46. package/dist/db-postgres/schema/shop/index.js +1 -0
  47. package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
  48. package/dist/db-postgres/schema/shop/invoice.js +27 -0
  49. package/dist/db-postgres/schema/shop/order.d.ts +104 -0
  50. package/dist/db-postgres/schema/shop/order.js +8 -0
  51. package/dist/shop/adapters/fakturownia/client.d.ts +33 -0
  52. package/dist/shop/adapters/fakturownia/client.js +87 -0
  53. package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
  54. package/dist/shop/adapters/fakturownia/index.js +47 -0
  55. package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
  56. package/dist/shop/adapters/fakturownia/payload.js +45 -0
  57. package/dist/shop/adapters/payu/index.js +11 -0
  58. package/dist/shop/client/index.d.ts +7 -0
  59. package/dist/shop/http/checkout-handler.js +11 -0
  60. package/dist/shop/index.d.ts +4 -1
  61. package/dist/shop/index.js +3 -0
  62. package/dist/shop/nip.d.ts +12 -0
  63. package/dist/shop/nip.js +23 -0
  64. package/dist/shop/server/coupons.d.ts +10 -0
  65. package/dist/shop/server/coupons.js +19 -0
  66. package/dist/shop/server/email.d.ts +7 -3
  67. package/dist/shop/server/email.js +86 -112
  68. package/dist/shop/server/emailTemplateRegistry.d.ts +47 -0
  69. package/dist/shop/server/emailTemplateRegistry.js +288 -0
  70. package/dist/shop/server/invoices.d.ts +64 -0
  71. package/dist/shop/server/invoices.js +237 -0
  72. package/dist/shop/server/orders.d.ts +64 -1
  73. package/dist/shop/server/orders.js +155 -15
  74. package/dist/shop/templates/_partials/footer.en.html +4 -0
  75. package/dist/shop/templates/_partials/footer.pl.html +4 -0
  76. package/dist/shop/templates/_partials/header.en.html +4 -0
  77. package/dist/shop/templates/_partials/header.pl.html +4 -0
  78. package/dist/shop/templates/_partials/items.en.html +14 -0
  79. package/dist/shop/templates/_partials/items.pl.html +14 -0
  80. package/dist/shop/templates/_partials/tracking.en.html +7 -0
  81. package/dist/shop/templates/_partials/tracking.pl.html +7 -0
  82. package/dist/shop/templates/awaiting-payment.en.html +6 -0
  83. package/dist/shop/templates/awaiting-payment.pl.html +6 -0
  84. package/dist/shop/templates/cancelled.en.html +6 -0
  85. package/dist/shop/templates/cancelled.pl.html +6 -0
  86. package/dist/shop/templates/low-stock.en.html +14 -0
  87. package/dist/shop/templates/low-stock.pl.html +14 -0
  88. package/dist/shop/templates/order-completed.en.html +6 -0
  89. package/dist/shop/templates/order-completed.pl.html +6 -0
  90. package/dist/shop/templates/order-received.en.html +7 -0
  91. package/dist/shop/templates/order-received.pl.html +7 -0
  92. package/dist/shop/templates/payment-received.en.html +7 -0
  93. package/dist/shop/templates/payment-received.pl.html +7 -0
  94. package/dist/shop/templates/payment-rejected.en.html +6 -0
  95. package/dist/shop/templates/payment-rejected.pl.html +6 -0
  96. package/dist/shop/templates/preparing.en.html +7 -0
  97. package/dist/shop/templates/preparing.pl.html +7 -0
  98. package/dist/shop/templates/refunded.en.html +6 -0
  99. package/dist/shop/templates/refunded.pl.html +6 -0
  100. package/dist/shop/templates/shipped.en.html +7 -0
  101. package/dist/shop/templates/shipped.pl.html +7 -0
  102. package/dist/shop/types.d.ts +130 -1
  103. package/dist/sveltekit/index.d.ts +0 -1
  104. package/dist/sveltekit/index.js +0 -1
  105. package/dist/sveltekit/server/index.d.ts +1 -0
  106. package/dist/sveltekit/server/index.js +1 -0
  107. package/dist/types/adapters/email.d.ts +13 -0
  108. package/dist/types/cms.d.ts +30 -0
  109. package/dist/types/index.d.ts +1 -1
  110. package/dist/updates/0.28.0/index.d.ts +2 -0
  111. package/dist/updates/0.28.0/index.js +38 -0
  112. package/dist/updates/0.34.0/index.d.ts +2 -0
  113. package/dist/updates/0.34.0/index.js +17 -0
  114. package/dist/updates/index.js +5 -1
  115. package/package.json +7 -2
@@ -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
+ }
@@ -13,6 +13,43 @@ export declare class MixedPaymentPolicyError extends Error {
13
13
  readonly code = "MIXED_PAYMENT_POLICY";
14
14
  constructor(message?: string);
15
15
  }
16
+ /**
17
+ * @public
18
+ * Order statuses an admin is allowed to soft-delete (hide from the admin list).
19
+ * Restricted to states that never carry a settled payment or an issued invoice,
20
+ * so hiding one can't bury accounting/audit data. Paid-or-later and `refunded`
21
+ * orders are never deletable. The invoice guard in `softDeleteOrder` is the
22
+ * second line of defence.
23
+ */
24
+ export declare const DELETABLE_ORDER_STATUSES: Set<OrderStatus>;
25
+ /** @public Pure status-level deletability check (no DB / invoice lookup). */
26
+ export declare function isOrderDeletable(status: OrderStatus): boolean;
27
+ export type OrderDeletionDecision = {
28
+ ok: true;
29
+ } | {
30
+ ok: false;
31
+ reason: 'status' | 'invoice';
32
+ };
33
+ /**
34
+ * @public
35
+ * Pure decision: may this order be soft-deleted? Encodes both guards (status +
36
+ * existing invoice) so they're testable without a DB. `invoice` is the order's
37
+ * current invoice record (or null). An `issued`/`sent` invoice hard-blocks the
38
+ * delete; `pending`/`failed` invoices don't (no legal document was produced).
39
+ */
40
+ export declare function decideOrderDeletion(status: OrderStatus, invoice: {
41
+ status: 'pending' | 'issued' | 'sent' | 'failed';
42
+ } | null): OrderDeletionDecision;
43
+ /**
44
+ * @public
45
+ * Thrown by `softDeleteOrder` when the order can't be hidden: either its status
46
+ * isn't in {@link DELETABLE_ORDER_STATUSES} or it already has an issued invoice.
47
+ */
48
+ export declare class OrderNotDeletableError extends Error {
49
+ readonly reason: 'status' | 'invoice';
50
+ readonly code = "ORDER_NOT_DELETABLE";
51
+ constructor(reason: 'status' | 'invoice');
52
+ }
16
53
  export type OrderRow = typeof shopOrdersTable.$inferSelect;
17
54
  export type OrderItemRow = typeof shopOrderItemsTable.$inferSelect;
18
55
  export type OrderStatusHistoryRow = typeof shopOrderStatusHistoryTable.$inferSelect;
@@ -25,7 +62,11 @@ export interface CreateOrderInput {
25
62
  customerEmail: string;
26
63
  customerName?: string;
27
64
  customerPhone?: string;
65
+ customerNip?: string;
66
+ customerCompanyName?: string;
28
67
  shippingAddress?: Record<string, string>;
68
+ billingAddress?: Record<string, string>;
69
+ invoiceRequested?: boolean;
29
70
  shippingMethodId: string;
30
71
  carrierRef?: string;
31
72
  paymentMethod: string;
@@ -83,9 +124,31 @@ export declare function getOrderItems(orderId: string): Promise<OrderItemRow[]>;
83
124
  export declare function getOrderStatusHistory(orderId: string): Promise<OrderStatusHistoryRow[]>;
84
125
  export interface ListOrdersOptions {
85
126
  status?: OrderStatus;
86
- email?: string;
127
+ search?: string;
87
128
  limit?: number;
88
129
  offset?: number;
130
+ /**
131
+ * Soft-delete visibility. `'exclude'` (default) hides soft-deleted orders —
132
+ * the normal admin/customer list. `'only'` returns just the trash; `'include'`
133
+ * returns everything regardless of `deletedAt`.
134
+ */
135
+ deleted?: 'exclude' | 'only' | 'include';
89
136
  }
90
137
  export declare function listOrders(opts?: ListOrdersOptions): Promise<OrderRow[]>;
91
138
  export declare function countOrders(opts?: Omit<ListOrdersOptions, 'limit' | 'offset'>): Promise<number>;
139
+ /**
140
+ * @public
141
+ * Soft-delete an order: hide it from the admin/customer list without removing
142
+ * the row (accounting/audit safety). Idempotent — a no-op on an already-deleted
143
+ * order. Guards on {@link decideOrderDeletion}: throws {@link OrderNotDeletableError}
144
+ * when the status isn't deletable or an issued/sent invoice exists. Releases any
145
+ * active stock reservation immediately so a hidden, abandoned order never locks
146
+ * stock waiting for the TTL.
147
+ */
148
+ export declare function softDeleteOrder(orderId: string, deletedBy: string): Promise<OrderRow>;
149
+ /**
150
+ * @public
151
+ * Restore a soft-deleted order back to the visible list. Idempotent — a no-op
152
+ * on an order that isn't deleted.
153
+ */
154
+ export declare function restoreOrder(orderId: string): Promise<OrderRow>;
@@ -1,4 +1,4 @@
1
- import { and, asc, desc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
1
+ import { and, asc, desc, eq, ilike, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
2
2
  import { shopOrderItemsTable, shopOrderStatusHistoryTable, shopOrdersTable, shopProductVariantsTable, shopProductsTable, shopShippingMethodsTable, shopStockReservationsTable } from '../../db-postgres/schema/shop/index.js';
3
3
  import { getCMS } from '../../core/cms.js';
4
4
  import { getShopDb, requireShopConfig } from './db.js';
@@ -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 { getInvoiceByOrderId, maybeIssueInvoiceForOrder } from './invoices.js';
12
13
  import { CouponError, recordCouponRedemption, releaseCouponSlot, reserveCouponSlot, validateCoupon } from './coupons.js';
13
14
  /**
14
15
  * @public
@@ -26,6 +27,55 @@ export class MixedPaymentPolicyError extends Error {
26
27
  }
27
28
  }
28
29
  const STOCK_RESERVATION_TTL_MINUTES = 30;
30
+ /**
31
+ * @public
32
+ * Order statuses an admin is allowed to soft-delete (hide from the admin list).
33
+ * Restricted to states that never carry a settled payment or an issued invoice,
34
+ * so hiding one can't bury accounting/audit data. Paid-or-later and `refunded`
35
+ * orders are never deletable. The invoice guard in `softDeleteOrder` is the
36
+ * second line of defence.
37
+ */
38
+ export const DELETABLE_ORDER_STATUSES = new Set([
39
+ 'new',
40
+ 'awaitingPayment',
41
+ 'cancelled',
42
+ 'paymentRejected'
43
+ ]);
44
+ /** @public Pure status-level deletability check (no DB / invoice lookup). */
45
+ export function isOrderDeletable(status) {
46
+ return DELETABLE_ORDER_STATUSES.has(status);
47
+ }
48
+ /**
49
+ * @public
50
+ * Pure decision: may this order be soft-deleted? Encodes both guards (status +
51
+ * existing invoice) so they're testable without a DB. `invoice` is the order's
52
+ * current invoice record (or null). An `issued`/`sent` invoice hard-blocks the
53
+ * delete; `pending`/`failed` invoices don't (no legal document was produced).
54
+ */
55
+ export function decideOrderDeletion(status, invoice) {
56
+ if (!isOrderDeletable(status))
57
+ return { ok: false, reason: 'status' };
58
+ if (invoice && (invoice.status === 'issued' || invoice.status === 'sent')) {
59
+ return { ok: false, reason: 'invoice' };
60
+ }
61
+ return { ok: true };
62
+ }
63
+ /**
64
+ * @public
65
+ * Thrown by `softDeleteOrder` when the order can't be hidden: either its status
66
+ * isn't in {@link DELETABLE_ORDER_STATUSES} or it already has an issued invoice.
67
+ */
68
+ export class OrderNotDeletableError extends Error {
69
+ reason;
70
+ code = 'ORDER_NOT_DELETABLE';
71
+ constructor(reason) {
72
+ super(reason === 'invoice'
73
+ ? 'Order has an issued invoice and cannot be deleted.'
74
+ : 'Order status does not allow deletion.');
75
+ this.reason = reason;
76
+ this.name = 'OrderNotDeletableError';
77
+ }
78
+ }
29
79
  async function purgeExpiredReservations() {
30
80
  const db = getShopDb();
31
81
  await db
@@ -237,7 +287,11 @@ export async function createOrderFromCart(input) {
237
287
  customerEmail: input.customerEmail,
238
288
  customerName: input.customerName ?? null,
239
289
  customerPhone: input.customerPhone ?? null,
290
+ customerNip: input.customerNip ?? null,
291
+ customerCompanyName: input.customerCompanyName ?? null,
240
292
  shippingAddress: input.shippingAddress ?? null,
293
+ billingAddress: input.billingAddress ?? null,
294
+ invoiceRequested: input.invoiceRequested ?? false,
241
295
  totalNet,
242
296
  totalGross,
243
297
  vatAmount: totalVat,
@@ -356,9 +410,18 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
356
410
  })
357
411
  .where(eq(shopOrdersTable.id, orderId));
358
412
  }
413
+ // Auto-restore: a soft-deleted order may only sit in a deletable status. If
414
+ // it transitions out of that set (e.g. a late payment webhook flips a hidden
415
+ // `awaitingPayment` order to `paid`), un-hide it so settled orders are never
416
+ // buried in the trash.
417
+ const autoRestore = order.deletedAt != null && !isOrderDeletable(status);
359
418
  await db
360
419
  .update(shopOrdersTable)
361
- .set({ status, updatedAt: new Date() })
420
+ .set({
421
+ status,
422
+ updatedAt: new Date(),
423
+ ...(autoRestore ? { deletedAt: null, deletedBy: null } : {})
424
+ })
362
425
  .where(eq(shopOrdersTable.id, orderId));
363
426
  await db.insert(shopOrderStatusHistoryTable).values({
364
427
  orderId,
@@ -430,6 +493,18 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
430
493
  }
431
494
  const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
432
495
  void sendOrderStatusEmail(orderId, status);
496
+ // Auto-issue an invoice once the order is paid. Fail-open / fire-and-forget —
497
+ // the guard inside skips deposit orders that still owe a balance.
498
+ if (status === 'paid')
499
+ void maybeIssueInvoiceForOrder(orderId);
500
+ // User-land `onOrderPaid` hook — fires on transition INTO `paid` only
501
+ // (no-op when oldStatus === 'paid'). Errors swallowed so a buggy callback
502
+ // never blocks the webhook / status write.
503
+ if (order.status !== 'paid' && status === 'paid' && shop.onOrderPaid && updated) {
504
+ Promise.resolve(shop.onOrderPaid(updated)).catch((e) => {
505
+ console.error('[onOrderPaid] callback failed', e);
506
+ });
507
+ }
433
508
  return updated;
434
509
  }
435
510
  /**
@@ -461,6 +536,8 @@ export async function markBalancePaid(orderId) {
461
536
  })
462
537
  .where(eq(shopOrdersTable.id, orderId));
463
538
  const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
539
+ // Balance cleared → the order is now fully paid; issue its invoice.
540
+ void maybeIssueInvoiceForOrder(orderId);
464
541
  return updated ?? null;
465
542
  }
466
543
  export async function setPaymentProviderRef(orderId, ref) {
@@ -522,7 +599,12 @@ export async function getOrderById(id) {
522
599
  }
523
600
  export async function getOrderByNumber(number) {
524
601
  const db = getShopDb();
525
- const [row] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.number, number));
602
+ // Customer-facing lookup a soft-deleted order must not resolve on the
603
+ // storefront. (Admin uses getOrderById, which intentionally ignores deletedAt.)
604
+ const [row] = await db
605
+ .select()
606
+ .from(shopOrdersTable)
607
+ .where(and(eq(shopOrdersTable.number, number), isNull(shopOrdersTable.deletedAt)));
526
608
  return row ?? null;
527
609
  }
528
610
  export async function getOrderItems(orderId) {
@@ -537,16 +619,28 @@ export async function getOrderStatusHistory(orderId) {
537
619
  .where(eq(shopOrderStatusHistoryTable.orderId, orderId))
538
620
  .orderBy(asc(shopOrderStatusHistoryTable.changedAt));
539
621
  }
540
- export async function listOrders(opts = {}) {
541
- const db = getShopDb();
622
+ function escapeLike(value) {
623
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
624
+ }
625
+ function buildOrderListConditions(opts) {
542
626
  const conditions = [];
627
+ const deleted = opts.deleted ?? 'exclude';
628
+ if (deleted === 'exclude')
629
+ conditions.push(isNull(shopOrdersTable.deletedAt));
630
+ else if (deleted === 'only')
631
+ conditions.push(isNotNull(shopOrdersTable.deletedAt));
543
632
  if (opts.status)
544
633
  conditions.push(eq(shopOrdersTable.status, opts.status));
545
- if (opts.email)
546
- conditions.push(eq(shopOrdersTable.customerEmail, opts.email));
547
- // Exclude soft-deleted if such a column existed; none now
548
- // Avoid unused import warning
549
- void isNull;
634
+ const search = opts.search?.trim();
635
+ if (search) {
636
+ const pattern = `%${escapeLike(search)}%`;
637
+ conditions.push(or(ilike(shopOrdersTable.number, pattern), ilike(shopOrdersTable.customerEmail, pattern), ilike(shopOrdersTable.customerName, pattern)));
638
+ }
639
+ return conditions;
640
+ }
641
+ export async function listOrders(opts = {}) {
642
+ const db = getShopDb();
643
+ const conditions = buildOrderListConditions(opts);
550
644
  const where = conditions.length > 0 ? and(...conditions) : undefined;
551
645
  return db
552
646
  .select()
@@ -558,11 +652,7 @@ export async function listOrders(opts = {}) {
558
652
  }
559
653
  export async function countOrders(opts = {}) {
560
654
  const db = getShopDb();
561
- const conditions = [];
562
- if (opts.status)
563
- conditions.push(eq(shopOrdersTable.status, opts.status));
564
- if (opts.email)
565
- conditions.push(eq(shopOrdersTable.customerEmail, opts.email));
655
+ const conditions = buildOrderListConditions(opts);
566
656
  const where = conditions.length > 0 ? and(...conditions) : undefined;
567
657
  const [row] = await db
568
658
  .select({ count: sql `count(*)::int` })
@@ -570,3 +660,53 @@ export async function countOrders(opts = {}) {
570
660
  .where(where);
571
661
  return row?.count ?? 0;
572
662
  }
663
+ /**
664
+ * @public
665
+ * Soft-delete an order: hide it from the admin/customer list without removing
666
+ * the row (accounting/audit safety). Idempotent — a no-op on an already-deleted
667
+ * order. Guards on {@link decideOrderDeletion}: throws {@link OrderNotDeletableError}
668
+ * when the status isn't deletable or an issued/sent invoice exists. Releases any
669
+ * active stock reservation immediately so a hidden, abandoned order never locks
670
+ * stock waiting for the TTL.
671
+ */
672
+ export async function softDeleteOrder(orderId, deletedBy) {
673
+ const db = getShopDb();
674
+ const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
675
+ if (!order)
676
+ throw new Error('Order not found');
677
+ if (order.deletedAt)
678
+ return order;
679
+ const invoice = await getInvoiceByOrderId(orderId);
680
+ const decision = decideOrderDeletion(order.status, invoice);
681
+ if (!decision.ok)
682
+ throw new OrderNotDeletableError(decision.reason);
683
+ // Free held stock right away (no rows when the stock feature is off).
684
+ await db
685
+ .delete(shopStockReservationsTable)
686
+ .where(eq(shopStockReservationsTable.orderId, orderId));
687
+ await db
688
+ .update(shopOrdersTable)
689
+ .set({ deletedAt: new Date(), deletedBy, updatedAt: new Date() })
690
+ .where(eq(shopOrdersTable.id, orderId));
691
+ const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
692
+ return updated;
693
+ }
694
+ /**
695
+ * @public
696
+ * Restore a soft-deleted order back to the visible list. Idempotent — a no-op
697
+ * on an order that isn't deleted.
698
+ */
699
+ export async function restoreOrder(orderId) {
700
+ const db = getShopDb();
701
+ const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
702
+ if (!order)
703
+ throw new Error('Order not found');
704
+ if (!order.deletedAt)
705
+ return order;
706
+ await db
707
+ .update(shopOrdersTable)
708
+ .set({ deletedAt: null, deletedBy: null, updatedAt: new Date() })
709
+ .where(eq(shopOrdersTable.id, orderId));
710
+ const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
711
+ return updated;
712
+ }
@@ -0,0 +1,4 @@
1
+ </div>
2
+ <p style="text-align:center;color:#8888A0;font-size:12px;margin-top:16px;">AriaCMS · {{order.customerEmail}}</p>
3
+ </div>
4
+ </body></html>
@@ -0,0 +1,4 @@
1
+ </div>
2
+ <p style="text-align:center;color:#8888A0;font-size:12px;margin-top:16px;">AriaCMS · {{order.customerEmail}}</p>
3
+ </div>
4
+ </body></html>
@@ -0,0 +1,4 @@
1
+ <!doctype html>
2
+ <html lang="en"><body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:#f5f5f8;color:#1a1a2e;">
3
+ <div style="max-width:560px;margin:0 auto;padding:24px;">
4
+ <div style="background:#fff;border-radius:12px;padding:28px;">
@@ -0,0 +1,4 @@
1
+ <!doctype html>
2
+ <html lang="pl"><body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:#f5f5f8;color:#1a1a2e;">
3
+ <div style="max-width:560px;margin:0 auto;padding:24px;">
4
+ <div style="background:#fff;border-radius:12px;padding:28px;">
@@ -0,0 +1,14 @@
1
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
2
+ <thead><tr style="background:#f4f2fa;"><th align="left" style="padding:8px;">Item</th><th style="padding:8px;">Qty</th><th align="right" style="padding:8px;">Total</th></tr></thead>
3
+ <tbody>
4
+ {{#each items}}
5
+ <tr><td style="padding:6px 8px;border-bottom:1px solid #eee;">{{name}}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:center;">{{qty}}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:right;">{{lineGross}}</td></tr>
6
+ {{/each}}
7
+ </tbody>
8
+ </table>
9
+ <div style="margin-top:16px;text-align:right;font-size:14px;line-height:1.7;">
10
+ <div>Shipping: <strong>{{order.shippingGross}}</strong></div>
11
+ {{#if order.hasDiscount}}<div>Discount ({{order.couponCode}}): <strong style="color:#5B4A9E;">−{{order.discountAmount}}</strong></div>{{/if}}
12
+ <div style="font-size:16px;">Total (gross): <strong style="color:#5B4A9E;">{{order.totalGross}}</strong></div>
13
+ <div style="color:#8888A0;font-size:12px;">net {{order.totalNet}} · VAT {{order.vatAmount}}</div>
14
+ </div>
@@ -0,0 +1,14 @@
1
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
2
+ <thead><tr style="background:#f4f2fa;"><th align="left" style="padding:8px;">Pozycja</th><th style="padding:8px;">Ilość</th><th align="right" style="padding:8px;">Suma</th></tr></thead>
3
+ <tbody>
4
+ {{#each items}}
5
+ <tr><td style="padding:6px 8px;border-bottom:1px solid #eee;">{{name}}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:center;">{{qty}}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:right;">{{lineGross}}</td></tr>
6
+ {{/each}}
7
+ </tbody>
8
+ </table>
9
+ <div style="margin-top:16px;text-align:right;font-size:14px;line-height:1.7;">
10
+ <div>Wysyłka: <strong>{{order.shippingGross}}</strong></div>
11
+ {{#if order.hasDiscount}}<div>Rabat ({{order.couponCode}}): <strong style="color:#5B4A9E;">−{{order.discountAmount}}</strong></div>{{/if}}
12
+ <div style="font-size:16px;">Razem (brutto): <strong style="color:#5B4A9E;">{{order.totalGross}}</strong></div>
13
+ <div style="color:#8888A0;font-size:12px;">netto {{order.totalNet}} · VAT {{order.vatAmount}}</div>
14
+ </div>
@@ -0,0 +1,7 @@
1
+ {{#if tracking}}
2
+ <div style="margin-top:20px;padding:16px;background:#F4F2FA;border-radius:10px;font-size:14px;">
3
+ <div style="color:#555566;margin-bottom:4px;">{{tracking.label}}</div>
4
+ <div style="font-family:ui-monospace,monospace;font-weight:700;word-break:break-all;">{{tracking.number}}</div>
5
+ {{#if tracking.url}}<a href="{{tracking.url}}" style="display:inline-block;margin-top:8px;color:#5B4A9E;font-weight:600;text-decoration:none;">{{tracking.linkLabel}}</a>{{/if}}
6
+ </div>
7
+ {{/if}}
@@ -0,0 +1,7 @@
1
+ {{#if tracking}}
2
+ <div style="margin-top:20px;padding:16px;background:#F4F2FA;border-radius:10px;font-size:14px;">
3
+ <div style="color:#555566;margin-bottom:4px;">{{tracking.label}}</div>
4
+ <div style="font-family:ui-monospace,monospace;font-weight:700;word-break:break-all;">{{tracking.number}}</div>
5
+ {{#if tracking.url}}<a href="{{tracking.url}}" style="display:inline-block;margin-top:8px;color:#5B4A9E;font-weight:600;text-decoration:none;">{{tracking.linkLabel}}</a>{{/if}}
6
+ </div>
7
+ {{/if}}
@@ -0,0 +1,6 @@
1
+ {{> header}}
2
+ <h1 style="font-size:20px;font-weight:800;margin:0 0 8px;">Awaiting payment</h1>
3
+ <p style="margin:0 0 20px;color:#555566;line-height:1.5;">Your order {{order.number}} has been placed. Waiting for payment per the chosen method.</p>
4
+ {{> items}}
5
+ {{#if viewUrl}}<div style="margin-top:24px;text-align:center;"><a href="{{viewUrl}}" style="display:inline-block;background:#5B4A9E;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600;">{{viewLinkLabel}}</a></div>{{/if}}
6
+ {{> footer}}
@@ -0,0 +1,6 @@
1
+ {{> header}}
2
+ <h1 style="font-size:20px;font-weight:800;margin:0 0 8px;">Czekamy na płatność</h1>
3
+ <p style="margin:0 0 20px;color:#555566;line-height:1.5;">Otrzymaliśmy zamówienie {{order.number}}. Czekamy na płatność zgodnie z wybraną metodą.</p>
4
+ {{> items}}
5
+ {{#if viewUrl}}<div style="margin-top:24px;text-align:center;"><a href="{{viewUrl}}" style="display:inline-block;background:#5B4A9E;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600;">{{viewLinkLabel}}</a></div>{{/if}}
6
+ {{> footer}}
@@ -0,0 +1,6 @@
1
+ {{> header}}
2
+ <h1 style="font-size:20px;font-weight:800;margin:0 0 8px;">Order cancelled</h1>
3
+ <p style="margin:0 0 20px;color:#555566;line-height:1.5;">Order {{order.number}} has been cancelled.</p>
4
+ {{> items}}
5
+ {{#if viewUrl}}<div style="margin-top:24px;text-align:center;"><a href="{{viewUrl}}" style="display:inline-block;background:#5B4A9E;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600;">{{viewLinkLabel}}</a></div>{{/if}}
6
+ {{> footer}}