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.
- package/API.md +58 -14
- package/CHANGELOG.md +59 -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 +156 -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 +125 -40
- package/dist/admin/remote/shop.remote.js +59 -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/index.d.ts +1 -0
- package/dist/db-postgres/schema/shop/index.js +1 -0
- package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
- package/dist/db-postgres/schema/shop/invoice.js +27 -0
- package/dist/db-postgres/schema/shop/order.d.ts +104 -0
- package/dist/db-postgres/schema/shop/order.js +8 -0
- package/dist/shop/adapters/fakturownia/client.d.ts +33 -0
- package/dist/shop/adapters/fakturownia/client.js +87 -0
- package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
- package/dist/shop/adapters/fakturownia/index.js +47 -0
- package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
- package/dist/shop/adapters/fakturownia/payload.js +45 -0
- package/dist/shop/adapters/payu/index.js +11 -0
- package/dist/shop/client/index.d.ts +7 -0
- package/dist/shop/http/checkout-handler.js +11 -0
- package/dist/shop/index.d.ts +4 -1
- package/dist/shop/index.js +3 -0
- package/dist/shop/nip.d.ts +12 -0
- package/dist/shop/nip.js +23 -0
- package/dist/shop/server/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/invoices.d.ts +64 -0
- package/dist/shop/server/invoices.js +237 -0
- package/dist/shop/server/orders.d.ts +64 -1
- package/dist/shop/server/orders.js +155 -15
- 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 +130 -1
- 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.28.0/index.d.ts +2 -0
- package/dist/updates/0.28.0/index.js +38 -0
- 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 +5 -1
- 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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
+
<!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}}
|