includio-cms 0.28.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 (99) hide show
  1. package/API.md +41 -13
  2. package/CHANGELOG.md +19 -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 +71 -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 +71 -44
  31. package/dist/admin/remote/shop.remote.js +41 -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/order.d.ts +34 -0
  46. package/dist/db-postgres/schema/shop/order.js +4 -0
  47. package/dist/shop/adapters/fakturownia/client.d.ts +5 -0
  48. package/dist/shop/adapters/fakturownia/client.js +20 -0
  49. package/dist/shop/adapters/fakturownia/index.js +11 -0
  50. package/dist/shop/adapters/payu/index.js +11 -0
  51. package/dist/shop/index.d.ts +1 -1
  52. package/dist/shop/server/coupons.d.ts +10 -0
  53. package/dist/shop/server/coupons.js +19 -0
  54. package/dist/shop/server/email.d.ts +7 -3
  55. package/dist/shop/server/email.js +86 -112
  56. package/dist/shop/server/emailTemplateRegistry.d.ts +47 -0
  57. package/dist/shop/server/emailTemplateRegistry.js +288 -0
  58. package/dist/shop/server/orders.d.ts +60 -1
  59. package/dist/shop/server/orders.js +145 -16
  60. package/dist/shop/templates/_partials/footer.en.html +4 -0
  61. package/dist/shop/templates/_partials/footer.pl.html +4 -0
  62. package/dist/shop/templates/_partials/header.en.html +4 -0
  63. package/dist/shop/templates/_partials/header.pl.html +4 -0
  64. package/dist/shop/templates/_partials/items.en.html +14 -0
  65. package/dist/shop/templates/_partials/items.pl.html +14 -0
  66. package/dist/shop/templates/_partials/tracking.en.html +7 -0
  67. package/dist/shop/templates/_partials/tracking.pl.html +7 -0
  68. package/dist/shop/templates/awaiting-payment.en.html +6 -0
  69. package/dist/shop/templates/awaiting-payment.pl.html +6 -0
  70. package/dist/shop/templates/cancelled.en.html +6 -0
  71. package/dist/shop/templates/cancelled.pl.html +6 -0
  72. package/dist/shop/templates/low-stock.en.html +14 -0
  73. package/dist/shop/templates/low-stock.pl.html +14 -0
  74. package/dist/shop/templates/order-completed.en.html +6 -0
  75. package/dist/shop/templates/order-completed.pl.html +6 -0
  76. package/dist/shop/templates/order-received.en.html +7 -0
  77. package/dist/shop/templates/order-received.pl.html +7 -0
  78. package/dist/shop/templates/payment-received.en.html +7 -0
  79. package/dist/shop/templates/payment-received.pl.html +7 -0
  80. package/dist/shop/templates/payment-rejected.en.html +6 -0
  81. package/dist/shop/templates/payment-rejected.pl.html +6 -0
  82. package/dist/shop/templates/preparing.en.html +7 -0
  83. package/dist/shop/templates/preparing.pl.html +7 -0
  84. package/dist/shop/templates/refunded.en.html +6 -0
  85. package/dist/shop/templates/refunded.pl.html +6 -0
  86. package/dist/shop/templates/shipped.en.html +7 -0
  87. package/dist/shop/templates/shipped.pl.html +7 -0
  88. package/dist/shop/types.d.ts +63 -0
  89. package/dist/sveltekit/index.d.ts +0 -1
  90. package/dist/sveltekit/index.js +0 -1
  91. package/dist/sveltekit/server/index.d.ts +1 -0
  92. package/dist/sveltekit/server/index.js +1 -0
  93. package/dist/types/adapters/email.d.ts +13 -0
  94. package/dist/types/cms.d.ts +30 -0
  95. package/dist/types/index.d.ts +1 -1
  96. package/dist/updates/0.34.0/index.d.ts +2 -0
  97. package/dist/updates/0.34.0/index.js +17 -0
  98. package/dist/updates/index.js +3 -1
  99. package/package.json +7 -2
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Shop email template registry — file-based Handlebars templates with
3
+ * 4-step resolution (project override → package default) and per-template
4
+ * CMS singleton auto-fetch. Used by `sendOrderStatusEmail` and
5
+ * `sendLowStockEmail` in email.ts.
6
+ *
7
+ * @internal — exported here for testing; not part of the public API.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import Handlebars from 'handlebars';
13
+ import { getCMS } from '../../core/cms.js';
14
+ import { resolveEntry } from '../../sveltekit/server/index.js';
15
+ const PACKAGE_TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
16
+ const cache = new Map();
17
+ let helpersRegistered = false;
18
+ const partialsRegisteredForLang = new Set();
19
+ function isDev() {
20
+ return process.env.NODE_ENV !== 'production';
21
+ }
22
+ function registerHelpers() {
23
+ if (helpersRegistered)
24
+ return;
25
+ Handlebars.registerHelper('currency', (value, currency) => {
26
+ const num = typeof value === 'number' ? value : Number(value);
27
+ const cur = typeof currency === 'string' ? currency : 'PLN';
28
+ if (!Number.isFinite(num))
29
+ return '';
30
+ return new Intl.NumberFormat('pl-PL', {
31
+ style: 'currency',
32
+ currency: cur,
33
+ minimumFractionDigits: 2
34
+ }).format(num / 100);
35
+ });
36
+ Handlebars.registerHelper('date', (value, format) => {
37
+ if (value == null)
38
+ return '';
39
+ const d = value instanceof Date ? value : new Date(value);
40
+ if (Number.isNaN(d.getTime()))
41
+ return '';
42
+ const fmt = format === 'long' ? 'long' : 'short';
43
+ if (fmt === 'long') {
44
+ return new Intl.DateTimeFormat('pl-PL', {
45
+ day: 'numeric',
46
+ month: 'long',
47
+ year: 'numeric'
48
+ }).format(d);
49
+ }
50
+ return new Intl.DateTimeFormat('pl-PL').format(d);
51
+ });
52
+ Handlebars.registerHelper('eq', (a, b) => a === b);
53
+ helpersRegistered = true;
54
+ }
55
+ /**
56
+ * 4-step lookup for a single template file.
57
+ * Returns `null` only if every step misses (shouldn't happen for built-in
58
+ * templates if the package is intact).
59
+ */
60
+ function resolveTemplatePath(projectDir, name, lang, fallbackLang) {
61
+ const candidates = [
62
+ { filePath: path.join(projectDir, `${name}.${lang}.html`), isOverride: true },
63
+ { filePath: path.join(projectDir, `${name}.html`), isOverride: true },
64
+ { filePath: path.join(PACKAGE_TEMPLATES_DIR, `${name}.${lang}.html`), isOverride: false },
65
+ { filePath: path.join(PACKAGE_TEMPLATES_DIR, `${name}.${fallbackLang}.html`), isOverride: false }
66
+ ];
67
+ for (const c of candidates) {
68
+ try {
69
+ const source = fs.readFileSync(c.filePath, 'utf8');
70
+ return { filePath: c.filePath, source, isOverride: c.isOverride };
71
+ }
72
+ catch {
73
+ // missing or unreadable, try next
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Traverse Handlebars AST and collect all `cms.<slug>.*` accessor slugs.
80
+ * Returns the unique set so the renderer can prefetch them.
81
+ */
82
+ function extractCmsSlugs(ast) {
83
+ const slugs = new Set();
84
+ const visit = (node) => {
85
+ if (!node || typeof node !== 'object')
86
+ return;
87
+ const type = node.type;
88
+ if (type === 'PathExpression') {
89
+ const parts = node.parts;
90
+ if (parts?.length >= 2 && parts[0] === 'cms') {
91
+ slugs.add(parts[1]);
92
+ }
93
+ }
94
+ // Recurse common AST child fields
95
+ const obj = node;
96
+ for (const key of [
97
+ 'body',
98
+ 'path',
99
+ 'params',
100
+ 'hash',
101
+ 'pairs',
102
+ 'value',
103
+ 'program',
104
+ 'inverse',
105
+ 'expression'
106
+ ]) {
107
+ const child = obj[key];
108
+ if (Array.isArray(child)) {
109
+ child.forEach((c) => visit(c));
110
+ }
111
+ else if (child && typeof child === 'object') {
112
+ visit(child);
113
+ }
114
+ }
115
+ };
116
+ visit(ast);
117
+ return slugs;
118
+ }
119
+ /**
120
+ * Register all partials for a given lang from `_partials/` (project override
121
+ * before package default). Idempotent in prod, re-registered each call in dev
122
+ * to pick up file changes.
123
+ */
124
+ function registerPartialsForLang(projectDir, lang, fallbackLang) {
125
+ const cacheKey = `${projectDir}|${lang}|${fallbackLang}`;
126
+ if (!isDev() && partialsRegisteredForLang.has(cacheKey))
127
+ return;
128
+ const seen = new Set();
129
+ const dirs = [
130
+ path.join(projectDir, '_partials'),
131
+ path.join(PACKAGE_TEMPLATES_DIR, '_partials')
132
+ ];
133
+ for (const dir of dirs) {
134
+ let entries;
135
+ try {
136
+ entries = fs.readdirSync(dir);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ for (const file of entries) {
142
+ // Match <name>.<lang>.html first, then <name>.<fallbackLang>.html
143
+ const match = file.match(/^(.+)\.([^.]+)\.html$/);
144
+ if (!match)
145
+ continue;
146
+ const [, partialName, fileLang] = match;
147
+ if (fileLang !== lang && fileLang !== fallbackLang)
148
+ continue;
149
+ // Prefer requested lang over fallback, prefer project over package
150
+ const registryKey = `${partialName}|${fileLang}`;
151
+ if (seen.has(registryKey))
152
+ continue;
153
+ seen.add(registryKey);
154
+ // Only register one per partialName — prefer lang match
155
+ if (Handlebars.partials[partialName] && fileLang !== lang)
156
+ continue;
157
+ try {
158
+ const source = fs.readFileSync(path.join(dir, file), 'utf8');
159
+ Handlebars.registerPartial(partialName, source);
160
+ }
161
+ catch {
162
+ // skip unreadable
163
+ }
164
+ }
165
+ }
166
+ if (!isDev())
167
+ partialsRegisteredForLang.add(cacheKey);
168
+ }
169
+ function compileOrThrow(source, filePath, strict) {
170
+ const ast = Handlebars.parse(source);
171
+ const cmsSlugs = extractCmsSlugs(ast);
172
+ const compiled = Handlebars.compile(source, { noEscape: false, strict });
173
+ // Touch filePath in any thrown error context
174
+ void filePath;
175
+ return { compiled, cmsSlugs, source };
176
+ }
177
+ /**
178
+ * Fetch all required CMS singletons in parallel and shape them as
179
+ * `{ <slug>: entryData }` for template context.
180
+ */
181
+ async function resolveCmsContext(slugs) {
182
+ if (slugs.size === 0)
183
+ return {};
184
+ const slugList = Array.from(slugs);
185
+ const entries = await Promise.all(slugList.map(async (slug) => {
186
+ try {
187
+ const entry = await resolveEntry({ collection: slug });
188
+ return [slug, entry?.data ?? {}];
189
+ }
190
+ catch {
191
+ return [slug, {}];
192
+ }
193
+ }));
194
+ return Object.fromEntries(entries);
195
+ }
196
+ /**
197
+ * Get a compiled template (cached in prod, re-compiled each call in dev).
198
+ * Falls back through the 4-step lookup. Throws if nothing is found.
199
+ */
200
+ function getCompiled(name, lang, opts) {
201
+ const key = `${opts.projectDir}|${name}|${lang}|${opts.fallbackLang}`;
202
+ if (!isDev()) {
203
+ const hit = cache.get(key);
204
+ if (hit)
205
+ return { ...hit, isOverride: false };
206
+ }
207
+ const resolved = resolveTemplatePath(opts.projectDir, name, lang, opts.fallbackLang);
208
+ if (!resolved) {
209
+ throw new Error(`[shop] No template found for "${name}" (lang=${lang}, fallback=${opts.fallbackLang}). ` +
210
+ `Looked in project dir "${opts.projectDir}" and package defaults. ` +
211
+ `This is likely a corrupt install or a misconfigured shop.emailTemplates.dir.`);
212
+ }
213
+ const strict = opts.strict === true;
214
+ let entry;
215
+ try {
216
+ entry = compileOrThrow(resolved.source, resolved.filePath, strict);
217
+ }
218
+ catch (err) {
219
+ // If the failing file was a project override, fall back to package default.
220
+ console.error(`[shop] Template compile failed at ${resolved.filePath}:`, err instanceof Error ? err.message : err);
221
+ if (resolved.isOverride) {
222
+ const pkgPath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${lang}.html`);
223
+ const fallbackPath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${opts.fallbackLang}.html`);
224
+ for (const p of [pkgPath, fallbackPath]) {
225
+ try {
226
+ const src = fs.readFileSync(p, 'utf8');
227
+ entry = compileOrThrow(src, p, strict);
228
+ break;
229
+ }
230
+ catch {
231
+ // continue
232
+ }
233
+ }
234
+ }
235
+ if (!entry)
236
+ throw err;
237
+ }
238
+ if (!isDev())
239
+ cache.set(key, entry);
240
+ return { ...entry, isOverride: resolved.isOverride };
241
+ }
242
+ /**
243
+ * Public render entry — used by sendOrderStatusEmail / sendLowStockEmail.
244
+ * Looks up the template, prefetches required CMS singletons, renders.
245
+ */
246
+ export async function renderEmailTemplate(name, lang, context, opts = {}) {
247
+ registerHelpers();
248
+ const cms = getCMS();
249
+ const fallbackLang = cms.languages[0] ?? 'pl';
250
+ const projectDir = path.resolve(opts.projectDir ?? path.join(process.cwd(), 'src/emails/shop'));
251
+ registerPartialsForLang(projectDir, lang, fallbackLang);
252
+ const entry = getCompiled(name, lang, { projectDir, fallbackLang, strict: opts.strict });
253
+ const cmsContext = await resolveCmsContext(entry.cmsSlugs);
254
+ const fullContext = { ...context, cms: cmsContext };
255
+ // strict/noEscape are compile-time options, already baked into entry.compiled.
256
+ return entry.compiled(fullContext);
257
+ }
258
+ /**
259
+ * Bootstrap-time smoke test — verify every required default template is
260
+ * present in the package. Called from initCMS after setShop.
261
+ * Lookup-only: does not compile or render, so cheap.
262
+ */
263
+ export function validateBuiltinTemplates(requiredNames, defaultLang) {
264
+ const missing = [];
265
+ for (const name of requiredNames) {
266
+ const filePath = path.join(PACKAGE_TEMPLATES_DIR, `${name}.${defaultLang}.html`);
267
+ if (!fs.existsSync(filePath))
268
+ missing.push(filePath);
269
+ }
270
+ if (missing.length > 0) {
271
+ throw new Error(`[shop] Built-in email templates missing — likely a corrupt install. ` +
272
+ `Reinstall includio-cms. Missing:\n ${missing.join('\n ')}`);
273
+ }
274
+ }
275
+ /** @internal — exposed for tests */
276
+ export const __testExports = {
277
+ extractCmsSlugs,
278
+ resolveTemplatePath,
279
+ PACKAGE_TEMPLATES_DIR,
280
+ clearCache: () => {
281
+ cache.clear();
282
+ partialsRegisteredForLang.clear();
283
+ // Reset Handlebars partial registry to avoid cross-test bleed.
284
+ for (const k of Object.keys(Handlebars.partials)) {
285
+ Handlebars.unregisterPartial(k);
286
+ }
287
+ }
288
+ };
@@ -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;
@@ -87,9 +124,31 @@ export declare function getOrderItems(orderId: string): Promise<OrderItemRow[]>;
87
124
  export declare function getOrderStatusHistory(orderId: string): Promise<OrderStatusHistoryRow[]>;
88
125
  export interface ListOrdersOptions {
89
126
  status?: OrderStatus;
90
- email?: string;
127
+ search?: string;
91
128
  limit?: number;
92
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';
93
136
  }
94
137
  export declare function listOrders(opts?: ListOrdersOptions): Promise<OrderRow[]>;
95
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,7 +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
+ import { getInvoiceByOrderId, maybeIssueInvoiceForOrder } from './invoices.js';
13
13
  import { CouponError, recordCouponRedemption, releaseCouponSlot, reserveCouponSlot, validateCoupon } from './coupons.js';
14
14
  /**
15
15
  * @public
@@ -27,6 +27,55 @@ export class MixedPaymentPolicyError extends Error {
27
27
  }
28
28
  }
29
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
+ }
30
79
  async function purgeExpiredReservations() {
31
80
  const db = getShopDb();
32
81
  await db
@@ -361,9 +410,18 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
361
410
  })
362
411
  .where(eq(shopOrdersTable.id, orderId));
363
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);
364
418
  await db
365
419
  .update(shopOrdersTable)
366
- .set({ status, updatedAt: new Date() })
420
+ .set({
421
+ status,
422
+ updatedAt: new Date(),
423
+ ...(autoRestore ? { deletedAt: null, deletedBy: null } : {})
424
+ })
367
425
  .where(eq(shopOrdersTable.id, orderId));
368
426
  await db.insert(shopOrderStatusHistoryTable).values({
369
427
  orderId,
@@ -439,6 +497,14 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
439
497
  // the guard inside skips deposit orders that still owe a balance.
440
498
  if (status === 'paid')
441
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
+ }
442
508
  return updated;
443
509
  }
444
510
  /**
@@ -533,7 +599,12 @@ export async function getOrderById(id) {
533
599
  }
534
600
  export async function getOrderByNumber(number) {
535
601
  const db = getShopDb();
536
- 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)));
537
608
  return row ?? null;
538
609
  }
539
610
  export async function getOrderItems(orderId) {
@@ -548,16 +619,28 @@ export async function getOrderStatusHistory(orderId) {
548
619
  .where(eq(shopOrderStatusHistoryTable.orderId, orderId))
549
620
  .orderBy(asc(shopOrderStatusHistoryTable.changedAt));
550
621
  }
551
- export async function listOrders(opts = {}) {
552
- const db = getShopDb();
622
+ function escapeLike(value) {
623
+ return value.replace(/[\\%_]/g, (m) => `\\${m}`);
624
+ }
625
+ function buildOrderListConditions(opts) {
553
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));
554
632
  if (opts.status)
555
633
  conditions.push(eq(shopOrdersTable.status, opts.status));
556
- if (opts.email)
557
- conditions.push(eq(shopOrdersTable.customerEmail, opts.email));
558
- // Exclude soft-deleted if such a column existed; none now
559
- // Avoid unused import warning
560
- 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);
561
644
  const where = conditions.length > 0 ? and(...conditions) : undefined;
562
645
  return db
563
646
  .select()
@@ -569,11 +652,7 @@ export async function listOrders(opts = {}) {
569
652
  }
570
653
  export async function countOrders(opts = {}) {
571
654
  const db = getShopDb();
572
- const conditions = [];
573
- if (opts.status)
574
- conditions.push(eq(shopOrdersTable.status, opts.status));
575
- if (opts.email)
576
- conditions.push(eq(shopOrdersTable.customerEmail, opts.email));
655
+ const conditions = buildOrderListConditions(opts);
577
656
  const where = conditions.length > 0 ? and(...conditions) : undefined;
578
657
  const [row] = await db
579
658
  .select({ count: sql `count(*)::int` })
@@ -581,3 +660,53 @@ export async function countOrders(opts = {}) {
581
660
  .where(where);
582
661
  return row?.count ?? 0;
583
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}}