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.
- package/API.md +41 -13
- package/CHANGELOG.md +19 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +1 -0
- package/dist/admin/api/handler.js +4 -0
- package/dist/admin/api/integrations.d.ts +13 -0
- package/dist/admin/api/integrations.js +61 -0
- package/dist/admin/api/test-email.d.ts +9 -0
- package/dist/admin/api/test-email.js +39 -0
- package/dist/admin/auth-client.d.ts +543 -543
- package/dist/admin/client/index.d.ts +10 -0
- package/dist/admin/client/index.js +12 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +210 -0
- package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
- package/dist/admin/client/shop/restore-order-cell.svelte +29 -0
- package/dist/admin/client/shop/restore-order-cell.svelte.d.ts +8 -0
- package/dist/admin/client/shop/shop-order-detail-page.svelte +71 -1
- package/dist/admin/client/shop/shop-orders-list-page.svelte +113 -53
- package/dist/admin/components/layout/app-sidebar.svelte +2 -0
- package/dist/admin/components/layout/nav-custom.svelte +26 -0
- package/dist/admin/components/layout/nav-custom.svelte.d.ts +3 -0
- package/dist/admin/components/layout/page-header.svelte +13 -3
- package/dist/admin/components/layout/page-header.svelte.d.ts +13 -3
- package/dist/admin/remote/admin.remote.d.ts +7 -0
- package/dist/admin/remote/admin.remote.js +10 -0
- package/dist/admin/remote/entry.remote.d.ts +2 -2
- package/dist/admin/remote/index.d.ts +1 -0
- package/dist/admin/remote/index.js +1 -0
- package/dist/admin/remote/invite.d.ts +1 -1
- package/dist/admin/remote/shop.remote.d.ts +71 -44
- package/dist/admin/remote/shop.remote.js +41 -10
- package/dist/admin/types.d.ts +15 -0
- package/dist/admin/utils/csv-export.d.ts +45 -0
- package/dist/admin/utils/csv-export.js +61 -0
- package/dist/cli/scaffold/admin.js +1 -1
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/dist/core/cms.d.ts +44 -2
- package/dist/core/cms.js +64 -0
- package/dist/core/index.d.ts +2 -4
- package/dist/core/index.js +1 -4
- package/dist/core/server/index.d.ts +4 -1
- package/dist/core/server/index.js +4 -1
- package/dist/db-postgres/schema/shop/order.d.ts +34 -0
- package/dist/db-postgres/schema/shop/order.js +4 -0
- package/dist/shop/adapters/fakturownia/client.d.ts +5 -0
- package/dist/shop/adapters/fakturownia/client.js +20 -0
- package/dist/shop/adapters/fakturownia/index.js +11 -0
- package/dist/shop/adapters/payu/index.js +11 -0
- package/dist/shop/index.d.ts +1 -1
- package/dist/shop/server/coupons.d.ts +10 -0
- package/dist/shop/server/coupons.js +19 -0
- package/dist/shop/server/email.d.ts +7 -3
- package/dist/shop/server/email.js +86 -112
- package/dist/shop/server/emailTemplateRegistry.d.ts +47 -0
- package/dist/shop/server/emailTemplateRegistry.js +288 -0
- package/dist/shop/server/orders.d.ts +60 -1
- package/dist/shop/server/orders.js +145 -16
- package/dist/shop/templates/_partials/footer.en.html +4 -0
- package/dist/shop/templates/_partials/footer.pl.html +4 -0
- package/dist/shop/templates/_partials/header.en.html +4 -0
- package/dist/shop/templates/_partials/header.pl.html +4 -0
- package/dist/shop/templates/_partials/items.en.html +14 -0
- package/dist/shop/templates/_partials/items.pl.html +14 -0
- package/dist/shop/templates/_partials/tracking.en.html +7 -0
- package/dist/shop/templates/_partials/tracking.pl.html +7 -0
- package/dist/shop/templates/awaiting-payment.en.html +6 -0
- package/dist/shop/templates/awaiting-payment.pl.html +6 -0
- package/dist/shop/templates/cancelled.en.html +6 -0
- package/dist/shop/templates/cancelled.pl.html +6 -0
- package/dist/shop/templates/low-stock.en.html +14 -0
- package/dist/shop/templates/low-stock.pl.html +14 -0
- package/dist/shop/templates/order-completed.en.html +6 -0
- package/dist/shop/templates/order-completed.pl.html +6 -0
- package/dist/shop/templates/order-received.en.html +7 -0
- package/dist/shop/templates/order-received.pl.html +7 -0
- package/dist/shop/templates/payment-received.en.html +7 -0
- package/dist/shop/templates/payment-received.pl.html +7 -0
- package/dist/shop/templates/payment-rejected.en.html +6 -0
- package/dist/shop/templates/payment-rejected.pl.html +6 -0
- package/dist/shop/templates/preparing.en.html +7 -0
- package/dist/shop/templates/preparing.pl.html +7 -0
- package/dist/shop/templates/refunded.en.html +6 -0
- package/dist/shop/templates/refunded.pl.html +6 -0
- package/dist/shop/templates/shipped.en.html +7 -0
- package/dist/shop/templates/shipped.pl.html +7 -0
- package/dist/shop/types.d.ts +63 -0
- package/dist/sveltekit/index.d.ts +0 -1
- package/dist/sveltekit/index.js +0 -1
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +1 -0
- package/dist/types/adapters/email.d.ts +13 -0
- package/dist/types/cms.d.ts +30 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/updates/0.34.0/index.d.ts +2 -0
- package/dist/updates/0.34.0/index.js +17 -0
- package/dist/updates/index.js +3 -1
- 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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
+
<!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}}
|