includio-cms 0.24.0 → 0.25.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 +29 -6
- package/CHANGELOG.md +95 -0
- package/DOCS.md +80 -5
- package/ROADMAP.md +1 -0
- package/dist/admin/client/index.d.ts +3 -0
- package/dist/admin/client/index.js +3 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte +44 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/coupon-form.svelte +170 -0
- package/dist/admin/client/shop/coupon-form.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupon-new-page.svelte +25 -0
- package/dist/admin/client/shop/coupon-new-page.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupons-list-page.svelte +135 -0
- package/dist/admin/client/shop/coupons-list-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/refund-dialog.svelte +161 -0
- package/dist/admin/client/shop/refund-dialog.svelte.d.ts +11 -0
- package/dist/admin/client/shop/shipping-method-edit-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-method-form.svelte +15 -21
- package/dist/admin/client/shop/shipping-method-new-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-methods-list-page.svelte +6 -6
- package/dist/admin/client/shop/shop-order-detail-page.svelte +107 -27
- package/dist/admin/client/shop/shop-orders-list-page.svelte +49 -11
- package/dist/admin/client/shop/shop-products-list-page.svelte +12 -11
- package/dist/admin/components/layout/lang.d.ts +1 -0
- package/dist/admin/components/layout/lang.js +4 -2
- package/dist/admin/components/layout/layout-renderer.svelte +12 -11
- package/dist/admin/components/layout/nav-breadcrumbs.svelte +3 -5
- package/dist/admin/components/layout/nav-shop.svelte +3 -1
- package/dist/admin/components/layout/nav-user.svelte +6 -4
- package/dist/admin/components/layout/site-header.svelte +11 -5
- package/dist/admin/remote/shop.remote.d.ts +122 -3
- package/dist/admin/remote/shop.remote.js +161 -5
- package/dist/db-postgres/schema/shop/couponRedemptions.d.ts +97 -0
- package/dist/db-postgres/schema/shop/couponRedemptions.js +21 -0
- package/dist/db-postgres/schema/shop/coupons.d.ts +197 -0
- package/dist/db-postgres/schema/shop/coupons.js +18 -0
- package/dist/db-postgres/schema/shop/index.d.ts +4 -0
- package/dist/db-postgres/schema/shop/index.js +4 -0
- package/dist/db-postgres/schema/shop/product.d.ts +17 -0
- package/dist/db-postgres/schema/shop/product.js +2 -0
- package/dist/db-postgres/schema/shop/refunds.d.ts +214 -0
- package/dist/db-postgres/schema/shop/refunds.js +21 -0
- package/dist/db-postgres/schema/shop/webhookEvents.d.ts +183 -0
- package/dist/db-postgres/schema/shop/webhookEvents.js +22 -0
- package/dist/shop/adapters/payu/client.d.ts +9 -0
- package/dist/shop/adapters/payu/client.js +29 -0
- package/dist/shop/adapters/payu/index.js +17 -1
- package/dist/shop/adapters/stripe/index.d.ts +64 -0
- package/dist/shop/adapters/stripe/index.js +169 -0
- package/dist/shop/adapters/stripe/payload.d.ts +38 -0
- package/dist/shop/adapters/stripe/payload.js +90 -0
- package/dist/shop/adapters/stripe/status-map.d.ts +11 -0
- package/dist/shop/adapters/stripe/status-map.js +31 -0
- package/dist/shop/cart/coupon-cookie.d.ts +7 -0
- package/dist/shop/cart/coupon-cookie.js +32 -0
- package/dist/shop/cart/types.d.ts +12 -0
- package/dist/shop/client/index.d.ts +118 -0
- package/dist/shop/client/index.js +39 -1
- package/dist/shop/http/cart-handler.d.ts +8 -0
- package/dist/shop/http/cart-handler.js +60 -1
- package/dist/shop/http/checkout-handler.js +7 -3
- package/dist/shop/http/index.d.ts +1 -1
- package/dist/shop/http/index.js +1 -1
- package/dist/shop/http/retry-payment-handler.js +1 -1
- package/dist/shop/http/webhook-handler.js +19 -1
- package/dist/shop/http/webhook-idempotency.d.ts +16 -0
- package/dist/shop/http/webhook-idempotency.js +51 -0
- package/dist/shop/http/webhook-logic.js +2 -1
- package/dist/shop/index.d.ts +3 -1
- package/dist/shop/index.js +3 -1
- package/dist/shop/pricing.d.ts +15 -0
- package/dist/shop/pricing.js +22 -0
- package/dist/shop/server/cart-hydrate.d.ts +1 -0
- package/dist/shop/server/cart-hydrate.js +58 -10
- package/dist/shop/server/coupons.d.ts +53 -0
- package/dist/shop/server/coupons.js +117 -0
- package/dist/shop/server/email.d.ts +15 -0
- package/dist/shop/server/email.js +46 -3
- package/dist/shop/server/orders.d.ts +1 -0
- package/dist/shop/server/orders.js +120 -54
- package/dist/shop/server/refund.d.ts +32 -0
- package/dist/shop/server/refund.js +140 -0
- package/dist/shop/svelte/InpostPicker.svelte +4 -7
- package/dist/shop/svelte/OrderStatus.svelte +6 -10
- package/dist/shop/svelte/labels.js +4 -2
- package/dist/shop/types.d.ts +41 -1
- package/dist/updates/0.25.0/index.d.ts +2 -0
- package/dist/updates/0.25.0/index.js +89 -0
- package/dist/updates/index.js +64 -1
- package/package.json +6 -1
|
@@ -22,7 +22,8 @@ const STATUS_SUBJECTS = {
|
|
|
22
22
|
sent: { pl: 'Zamówienie wysłane', en: 'Order shipped' },
|
|
23
23
|
done: { pl: 'Zamówienie zrealizowane', en: 'Order completed' },
|
|
24
24
|
cancelled: { pl: 'Zamówienie anulowane', en: 'Order cancelled' },
|
|
25
|
-
paymentRejected: { pl: 'Płatność odrzucona', en: 'Payment rejected' }
|
|
25
|
+
paymentRejected: { pl: 'Płatność odrzucona', en: 'Payment rejected' },
|
|
26
|
+
refunded: { pl: 'Zamówienie zwrócone', en: 'Order refunded' }
|
|
26
27
|
};
|
|
27
28
|
const STATUS_INTRO = {
|
|
28
29
|
new: {
|
|
@@ -56,6 +57,10 @@ const STATUS_INTRO = {
|
|
|
56
57
|
paymentRejected: {
|
|
57
58
|
pl: 'Płatność nie została zaksięgowana. Skontaktuj się z nami w razie pytań.',
|
|
58
59
|
en: 'Payment was not received. Please contact us if you have questions.'
|
|
60
|
+
},
|
|
61
|
+
refunded: {
|
|
62
|
+
pl: 'Środki zostały zwrócone. Powinny pojawić się na koncie w ciągu kilku dni roboczych.',
|
|
63
|
+
en: 'Your refund has been issued. It should reach your account within a few business days.'
|
|
59
64
|
}
|
|
60
65
|
};
|
|
61
66
|
const VIEW_LINK_LABEL = {
|
|
@@ -129,8 +134,7 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
129
134
|
})
|
|
130
135
|
: null;
|
|
131
136
|
let tracking;
|
|
132
|
-
if (order.trackingNumber &&
|
|
133
|
-
(status === 'sent' || status === 'done' || status === 'preparing')) {
|
|
137
|
+
if (order.trackingNumber && (status === 'sent' || status === 'done' || status === 'preparing')) {
|
|
134
138
|
const carrier = order.carrierType
|
|
135
139
|
? shop.carriers.find((c) => c.id === order.carrierType)
|
|
136
140
|
: undefined;
|
|
@@ -189,3 +193,42 @@ export async function sendOrderStatusEmail(orderId, status) {
|
|
|
189
193
|
// Swallow — don't block status change on email failures
|
|
190
194
|
}
|
|
191
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Notify the shop admin that a product crossed its low-stock threshold.
|
|
198
|
+
* Fire-and-forget — silently no-ops when no `shop.adminEmail` or no email
|
|
199
|
+
* adapter is configured. Stays inside email.ts to keep the adapter call
|
|
200
|
+
* site centralised (HTML, escaping, logging behaviour identical to status
|
|
201
|
+
* emails).
|
|
202
|
+
*
|
|
203
|
+
* @internal
|
|
204
|
+
*/
|
|
205
|
+
export async function sendLowStockEmail(input) {
|
|
206
|
+
const cms = getCMS();
|
|
207
|
+
const shop = requireShopConfig();
|
|
208
|
+
const adminEmail = shop.adminEmail;
|
|
209
|
+
if (!adminEmail)
|
|
210
|
+
return;
|
|
211
|
+
const emailAdapter = cms.emailAdapter;
|
|
212
|
+
if (!emailAdapter)
|
|
213
|
+
return;
|
|
214
|
+
const subject = `Niski stan magazynowy · ${input.productTitle}`;
|
|
215
|
+
const html = `<!doctype html>
|
|
216
|
+
<html><body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:#f5f5f8;color:#1a1a2e;">
|
|
217
|
+
<div style="max-width:560px;margin:0 auto;padding:24px;">
|
|
218
|
+
<div style="background:#fff;border-radius:12px;padding:28px;">
|
|
219
|
+
<h1 style="font-size:20px;font-weight:800;margin:0 0 8px;color:#C4893A;">Niski stan magazynowy</h1>
|
|
220
|
+
<p style="margin:0 0 16px;color:#555566;line-height:1.5;">
|
|
221
|
+
Produkt <strong>${escapeHtml(input.productTitle)}</strong>${input.variantSku ? ` (SKU ${escapeHtml(input.variantSku)})` : ''}
|
|
222
|
+
ma już tylko <strong>${input.stock}</strong> sztuk w magazynie (próg alertu: ${input.threshold}).
|
|
223
|
+
</p>
|
|
224
|
+
<p style="margin:0;color:#8888A0;font-size:13px;">Uzupełnij stan w panelu admina, żeby uniknąć przerwy w sprzedaży.</p>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</body></html>`;
|
|
228
|
+
try {
|
|
229
|
+
await emailAdapter.sendMail({ to: adminEmail, subject, html });
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
console.error('[shop] Failed to send low-stock email:', err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { and, asc, desc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
|
|
2
|
-
import { shopOrderItemsTable, shopOrderStatusHistoryTable, shopOrdersTable, shopProductVariantsTable, shopShippingMethodsTable, shopStockReservationsTable } from '../../db-postgres/schema/shop/index.js';
|
|
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';
|
|
5
5
|
import { hydrateCart } from './cart-hydrate.js';
|
|
6
6
|
import { resolveShippingPrice } from './shipping.js';
|
|
7
7
|
import { generateOrderNumber } from './order-number.js';
|
|
8
|
-
import { sendOrderStatusEmail } from './email.js';
|
|
8
|
+
import { sendLowStockEmail, sendOrderStatusEmail } from './email.js';
|
|
9
9
|
import { isPaymentMethodAllowed } from './payment-compat.js';
|
|
10
|
+
import { CouponError, recordCouponRedemption, releaseCouponSlot, reserveCouponSlot, validateCoupon } from './coupons.js';
|
|
10
11
|
const STOCK_RESERVATION_TTL_MINUTES = 30;
|
|
11
12
|
async function purgeExpiredReservations() {
|
|
12
13
|
const db = getShopDb();
|
|
@@ -65,8 +66,12 @@ export async function createOrderFromCart(input) {
|
|
|
65
66
|
if (!isPaymentMethodAllowed(input.paymentMethod, shippingMethod.allowedPaymentMethods)) {
|
|
66
67
|
throw new Error(`Payment method "${input.paymentMethod}" not available for the selected shipping method.`);
|
|
67
68
|
}
|
|
68
|
-
// Hydrate cart server-side (authoritative pricing)
|
|
69
|
-
|
|
69
|
+
// Hydrate cart server-side (authoritative pricing). Pass couponCode so the
|
|
70
|
+
// snapshot reflects the discounted lines + applied coupon.
|
|
71
|
+
const snapshot = await hydrateCart(input.cartItems, {
|
|
72
|
+
language: input.language,
|
|
73
|
+
couponCode: input.couponCode ?? null
|
|
74
|
+
});
|
|
70
75
|
if (snapshot.items.length === 0 || snapshot.itemCount === 0) {
|
|
71
76
|
throw new Error('Cart has no valid items.');
|
|
72
77
|
}
|
|
@@ -74,6 +79,24 @@ export async function createOrderFromCart(input) {
|
|
|
74
79
|
if (unavailable.length > 0) {
|
|
75
80
|
throw new Error('Some items are no longer available.');
|
|
76
81
|
}
|
|
82
|
+
// If user supplied a coupon, validate it explicitly here so a silent drop
|
|
83
|
+
// in hydrateCart (e.g. expired) raises a hard error at checkout — we don't
|
|
84
|
+
// want to silently charge full price.
|
|
85
|
+
let validatedCoupon = null;
|
|
86
|
+
if (input.couponCode && shop.features.coupons) {
|
|
87
|
+
const validation = await validateCoupon({
|
|
88
|
+
code: input.couponCode,
|
|
89
|
+
subtotalNet: snapshot.subtotalNet,
|
|
90
|
+
subtotalGross: snapshot.subtotalGross
|
|
91
|
+
});
|
|
92
|
+
if (!snapshot.coupon) {
|
|
93
|
+
throw new CouponError('invalid_code', `Coupon "${input.couponCode}" did not apply to this cart`);
|
|
94
|
+
}
|
|
95
|
+
validatedCoupon = {
|
|
96
|
+
id: validation.row.id,
|
|
97
|
+
discountAmount: snapshot.subtotalGross - snapshot.totalGross
|
|
98
|
+
};
|
|
99
|
+
}
|
|
77
100
|
// Stock availability under active reservations (when stock feature on)
|
|
78
101
|
if (shop.features.stock) {
|
|
79
102
|
await purgeExpiredReservations();
|
|
@@ -119,30 +142,45 @@ export async function createOrderFromCart(input) {
|
|
|
119
142
|
}
|
|
120
143
|
// Insert order + items + initial status history
|
|
121
144
|
const initialStatus = 'awaitingPayment';
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
// Reserve coupon slot atomically BEFORE order insert. If the order insert
|
|
146
|
+
// (or anything between) throws, releaseCouponSlot() rolls back the
|
|
147
|
+
// increment so the coupon stays available.
|
|
148
|
+
if (validatedCoupon) {
|
|
149
|
+
await reserveCouponSlot(validatedCoupon.id);
|
|
150
|
+
}
|
|
151
|
+
let order;
|
|
152
|
+
try {
|
|
153
|
+
const [inserted] = await db
|
|
154
|
+
.insert(shopOrdersTable)
|
|
155
|
+
.values({
|
|
156
|
+
number: orderNumber,
|
|
157
|
+
status: initialStatus,
|
|
158
|
+
currency: shop.currency,
|
|
159
|
+
customerEmail: input.customerEmail,
|
|
160
|
+
customerName: input.customerName ?? null,
|
|
161
|
+
customerPhone: input.customerPhone ?? null,
|
|
162
|
+
shippingAddress: input.shippingAddress ?? null,
|
|
163
|
+
totalNet,
|
|
164
|
+
totalGross,
|
|
165
|
+
vatAmount: totalVat,
|
|
166
|
+
shippingNet: shippingResolved.net,
|
|
167
|
+
shippingGross: shippingResolved.gross,
|
|
168
|
+
shippingMethodId: shippingMethod.id,
|
|
169
|
+
carrierType: shippingMethod.carrierType,
|
|
170
|
+
carrierRef: input.carrierRef ?? null,
|
|
171
|
+
paymentMethod: input.paymentMethod,
|
|
172
|
+
consents: consentSnapshot,
|
|
173
|
+
notes: input.notes ?? null,
|
|
174
|
+
language: input.language ?? cms.languages[0] ?? null
|
|
175
|
+
})
|
|
176
|
+
.returning();
|
|
177
|
+
order = inserted;
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (validatedCoupon)
|
|
181
|
+
await releaseCouponSlot(validatedCoupon.id);
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
146
184
|
const items = [];
|
|
147
185
|
for (const line of snapshot.items) {
|
|
148
186
|
const [row] = await db
|
|
@@ -154,7 +192,7 @@ export async function createOrderFromCart(input) {
|
|
|
154
192
|
nameSnapshot: {
|
|
155
193
|
product: line.productTitle ?? '',
|
|
156
194
|
variant: line.variantName && typeof line.variantName === 'object'
|
|
157
|
-
? Object.values(line.variantName)[0] ?? ''
|
|
195
|
+
? (Object.values(line.variantName)[0] ?? '')
|
|
158
196
|
: ''
|
|
159
197
|
},
|
|
160
198
|
skuSnapshot: line.variantSku ?? null,
|
|
@@ -172,6 +210,14 @@ export async function createOrderFromCart(input) {
|
|
|
172
210
|
note: null,
|
|
173
211
|
changedBy: 'system'
|
|
174
212
|
});
|
|
213
|
+
// Persist coupon redemption (UNIQUE(orderId) — idempotent on retry)
|
|
214
|
+
if (validatedCoupon) {
|
|
215
|
+
await recordCouponRedemption({
|
|
216
|
+
couponId: validatedCoupon.id,
|
|
217
|
+
orderId: order.id,
|
|
218
|
+
discountAmount: validatedCoupon.discountAmount
|
|
219
|
+
});
|
|
220
|
+
}
|
|
175
221
|
// Stock reservations with TTL
|
|
176
222
|
if (shop.features.stock) {
|
|
177
223
|
const expiresAt = new Date(Date.now() + STOCK_RESERVATION_TTL_MINUTES * 60 * 1000);
|
|
@@ -196,10 +242,7 @@ export async function createOrderFromCart(input) {
|
|
|
196
242
|
}
|
|
197
243
|
export async function updateOrderStatus(orderId, status, opts = {}) {
|
|
198
244
|
const db = getShopDb();
|
|
199
|
-
const [order] = await db
|
|
200
|
-
.select()
|
|
201
|
-
.from(shopOrdersTable)
|
|
202
|
-
.where(eq(shopOrdersTable.id, orderId));
|
|
245
|
+
const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
203
246
|
if (!order)
|
|
204
247
|
throw new Error('Order not found');
|
|
205
248
|
if (order.status === status)
|
|
@@ -215,7 +258,10 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
|
|
|
215
258
|
note: opts.note ?? null,
|
|
216
259
|
changedBy: opts.changedBy ?? 'admin'
|
|
217
260
|
});
|
|
218
|
-
// Stock fulfilment on paid — decrement stock and drop reservation
|
|
261
|
+
// Stock fulfilment on paid — decrement stock and drop reservation.
|
|
262
|
+
// After decrement, fire low-stock alert if any variant crossed its
|
|
263
|
+
// product's `lowStockThreshold` boundary as a result of THIS decrement
|
|
264
|
+
// (i.e. before-decrement was above threshold and after is at-or-below).
|
|
219
265
|
if (status === 'paid' && shop.features.stock) {
|
|
220
266
|
const items = await db
|
|
221
267
|
.select()
|
|
@@ -224,28 +270,57 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
|
|
|
224
270
|
for (const item of items) {
|
|
225
271
|
if (!item.variantId)
|
|
226
272
|
continue;
|
|
227
|
-
await db
|
|
273
|
+
const updated = await db
|
|
228
274
|
.update(shopProductVariantsTable)
|
|
229
275
|
.set({
|
|
230
276
|
stock: sql `GREATEST(${shopProductVariantsTable.stock} - ${item.qty}, 0)`
|
|
231
277
|
})
|
|
232
|
-
.where(and(eq(shopProductVariantsTable.id, item.variantId), sql `${shopProductVariantsTable.stock} IS NOT NULL`))
|
|
278
|
+
.where(and(eq(shopProductVariantsTable.id, item.variantId), sql `${shopProductVariantsTable.stock} IS NOT NULL`))
|
|
279
|
+
.returning({
|
|
280
|
+
id: shopProductVariantsTable.id,
|
|
281
|
+
stock: shopProductVariantsTable.stock,
|
|
282
|
+
sku: shopProductVariantsTable.sku,
|
|
283
|
+
productId: shopProductVariantsTable.productId
|
|
284
|
+
});
|
|
285
|
+
const row = updated[0];
|
|
286
|
+
if (!row)
|
|
287
|
+
continue;
|
|
288
|
+
// Threshold check — only if the freshly-decremented stock is at or
|
|
289
|
+
// below the product's threshold. Avoids spamming when the variant
|
|
290
|
+
// was already below threshold before this paid order.
|
|
291
|
+
const [product] = await db
|
|
292
|
+
.select({
|
|
293
|
+
lowStockThreshold: shopProductsTable.lowStockThreshold,
|
|
294
|
+
entryId: shopProductsTable.entryId
|
|
295
|
+
})
|
|
296
|
+
.from(shopProductsTable)
|
|
297
|
+
.where(eq(shopProductsTable.id, row.productId));
|
|
298
|
+
const threshold = product?.lowStockThreshold ?? null;
|
|
299
|
+
if (threshold != null && row.stock != null && row.stock <= threshold) {
|
|
300
|
+
const beforeStock = (row.stock ?? 0) + item.qty;
|
|
301
|
+
if (beforeStock > threshold) {
|
|
302
|
+
void sendLowStockEmail({
|
|
303
|
+
productTitle: item.nameSnapshot
|
|
304
|
+
? (item.nameSnapshot.product ?? row.sku ?? row.id)
|
|
305
|
+
: (row.sku ?? row.id),
|
|
306
|
+
variantSku: row.sku,
|
|
307
|
+
stock: row.stock,
|
|
308
|
+
threshold
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
233
312
|
}
|
|
234
313
|
await db
|
|
235
314
|
.delete(shopStockReservationsTable)
|
|
236
315
|
.where(eq(shopStockReservationsTable.orderId, orderId));
|
|
237
316
|
}
|
|
238
317
|
// Cancelled / paymentRejected → free reservations
|
|
239
|
-
if ((status === 'cancelled' || status === 'paymentRejected') &&
|
|
240
|
-
shop.features.stock) {
|
|
318
|
+
if ((status === 'cancelled' || status === 'paymentRejected') && shop.features.stock) {
|
|
241
319
|
await db
|
|
242
320
|
.delete(shopStockReservationsTable)
|
|
243
321
|
.where(eq(shopStockReservationsTable.orderId, orderId));
|
|
244
322
|
}
|
|
245
|
-
const [updated] = await db
|
|
246
|
-
.select()
|
|
247
|
-
.from(shopOrdersTable)
|
|
248
|
-
.where(eq(shopOrdersTable.id, orderId));
|
|
323
|
+
const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
249
324
|
void sendOrderStatusEmail(orderId, status);
|
|
250
325
|
return updated;
|
|
251
326
|
}
|
|
@@ -268,10 +343,7 @@ export async function setShipmentInfo(orderId, info) {
|
|
|
268
343
|
updatedAt: new Date()
|
|
269
344
|
})
|
|
270
345
|
.where(eq(shopOrdersTable.id, orderId));
|
|
271
|
-
const [row] = await db
|
|
272
|
-
.select()
|
|
273
|
-
.from(shopOrdersTable)
|
|
274
|
-
.where(eq(shopOrdersTable.id, orderId));
|
|
346
|
+
const [row] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
|
|
275
347
|
if (!row)
|
|
276
348
|
throw new Error('Order not found after shipment update.');
|
|
277
349
|
return row;
|
|
@@ -311,18 +383,12 @@ export async function getOrderById(id) {
|
|
|
311
383
|
}
|
|
312
384
|
export async function getOrderByNumber(number) {
|
|
313
385
|
const db = getShopDb();
|
|
314
|
-
const [row] = await db
|
|
315
|
-
.select()
|
|
316
|
-
.from(shopOrdersTable)
|
|
317
|
-
.where(eq(shopOrdersTable.number, number));
|
|
386
|
+
const [row] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.number, number));
|
|
318
387
|
return row ?? null;
|
|
319
388
|
}
|
|
320
389
|
export async function getOrderItems(orderId) {
|
|
321
390
|
const db = getShopDb();
|
|
322
|
-
return db
|
|
323
|
-
.select()
|
|
324
|
-
.from(shopOrderItemsTable)
|
|
325
|
-
.where(eq(shopOrderItemsTable.orderId, orderId));
|
|
391
|
+
return db.select().from(shopOrderItemsTable).where(eq(shopOrderItemsTable.orderId, orderId));
|
|
326
392
|
}
|
|
327
393
|
export async function getOrderStatusHistory(orderId) {
|
|
328
394
|
const db = getShopDb();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { shopRefundsTable } from '../../db-postgres/schema/shop/index.js';
|
|
2
|
+
export type ShopRefundRow = typeof shopRefundsTable.$inferSelect;
|
|
3
|
+
export declare class RefundError extends Error {
|
|
4
|
+
readonly code: 'order_not_found' | 'order_not_paid' | 'no_provider_ref' | 'unknown_provider' | 'refund_unsupported' | 'invalid_amount' | 'amount_exceeds_remaining' | 'provider_error';
|
|
5
|
+
readonly cause?: unknown | undefined;
|
|
6
|
+
constructor(code: 'order_not_found' | 'order_not_paid' | 'no_provider_ref' | 'unknown_provider' | 'refund_unsupported' | 'invalid_amount' | 'amount_exceeds_remaining' | 'provider_error', message: string, cause?: unknown | undefined);
|
|
7
|
+
}
|
|
8
|
+
export interface RefundOrderInput {
|
|
9
|
+
orderId: string;
|
|
10
|
+
/** Amount to refund in minor currency units. Omit for full remaining. */
|
|
11
|
+
amount?: number;
|
|
12
|
+
reason?: string;
|
|
13
|
+
createdBy?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface RefundOrderResult {
|
|
16
|
+
refund: ShopRefundRow;
|
|
17
|
+
remainingRefundable: number;
|
|
18
|
+
orderStatusChanged: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Sum of succeeded refunds for the given order, in minor units. */
|
|
21
|
+
export declare function getRefundedAmount(orderId: string): Promise<number>;
|
|
22
|
+
export declare function listRefunds(orderId: string): Promise<ShopRefundRow[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Refund an order — full or partial. Validates eligibility, records a pending
|
|
25
|
+
* row, calls the adapter, transitions the row to succeeded/failed and the
|
|
26
|
+
* order status to `refunded` when the full captured amount has been refunded.
|
|
27
|
+
*
|
|
28
|
+
* Throws `RefundError` on validation failures and provider errors. The
|
|
29
|
+
* `pending` refund row is marked `failed` on provider error so admin can see
|
|
30
|
+
* what was attempted.
|
|
31
|
+
*/
|
|
32
|
+
export declare function refundOrder(input: RefundOrderInput): Promise<RefundOrderResult>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { and, eq, sum } from 'drizzle-orm';
|
|
2
|
+
import { shopOrdersTable, shopRefundsTable } from '../../db-postgres/schema/shop/index.js';
|
|
3
|
+
import { getShopDb, requireShopConfig } from './db.js';
|
|
4
|
+
import { getOrderById, updateOrderStatus } from './orders.js';
|
|
5
|
+
export class RefundError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
cause;
|
|
8
|
+
constructor(code, message, cause) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.cause = cause;
|
|
12
|
+
this.name = 'RefundError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Sum of succeeded refunds for the given order, in minor units. */
|
|
16
|
+
export async function getRefundedAmount(orderId) {
|
|
17
|
+
const db = getShopDb();
|
|
18
|
+
const [row] = await db
|
|
19
|
+
.select({ total: sum(shopRefundsTable.amount) })
|
|
20
|
+
.from(shopRefundsTable)
|
|
21
|
+
.where(and(eq(shopRefundsTable.orderId, orderId), eq(shopRefundsTable.status, 'succeeded')));
|
|
22
|
+
const raw = row?.total;
|
|
23
|
+
if (raw == null)
|
|
24
|
+
return 0;
|
|
25
|
+
const n = typeof raw === 'string' ? Number(raw) : raw;
|
|
26
|
+
return Number.isFinite(n) ? n : 0;
|
|
27
|
+
}
|
|
28
|
+
export async function listRefunds(orderId) {
|
|
29
|
+
const db = getShopDb();
|
|
30
|
+
return db
|
|
31
|
+
.select()
|
|
32
|
+
.from(shopRefundsTable)
|
|
33
|
+
.where(eq(shopRefundsTable.orderId, orderId))
|
|
34
|
+
.orderBy(shopRefundsTable.createdAt);
|
|
35
|
+
}
|
|
36
|
+
function findAdapter(provider) {
|
|
37
|
+
const shop = requireShopConfig();
|
|
38
|
+
return shop.payment.find((a) => a.id === provider) ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Refund an order — full or partial. Validates eligibility, records a pending
|
|
42
|
+
* row, calls the adapter, transitions the row to succeeded/failed and the
|
|
43
|
+
* order status to `refunded` when the full captured amount has been refunded.
|
|
44
|
+
*
|
|
45
|
+
* Throws `RefundError` on validation failures and provider errors. The
|
|
46
|
+
* `pending` refund row is marked `failed` on provider error so admin can see
|
|
47
|
+
* what was attempted.
|
|
48
|
+
*/
|
|
49
|
+
export async function refundOrder(input) {
|
|
50
|
+
const db = getShopDb();
|
|
51
|
+
const order = await getOrderById(input.orderId);
|
|
52
|
+
if (!order)
|
|
53
|
+
throw new RefundError('order_not_found', `Order ${input.orderId} not found`);
|
|
54
|
+
if (order.status !== 'paid' &&
|
|
55
|
+
order.status !== 'preparing' &&
|
|
56
|
+
order.status !== 'sent' &&
|
|
57
|
+
order.status !== 'done') {
|
|
58
|
+
throw new RefundError('order_not_paid', `Order ${order.number} cannot be refunded — status is ${order.status}`);
|
|
59
|
+
}
|
|
60
|
+
if (!order.paymentMethod) {
|
|
61
|
+
throw new RefundError('unknown_provider', `Order ${order.number} has no payment method`);
|
|
62
|
+
}
|
|
63
|
+
if (!order.paymentProviderRef) {
|
|
64
|
+
throw new RefundError('no_provider_ref', `Order ${order.number} has no payment provider reference`);
|
|
65
|
+
}
|
|
66
|
+
const adapter = findAdapter(order.paymentMethod);
|
|
67
|
+
if (!adapter) {
|
|
68
|
+
throw new RefundError('unknown_provider', `Payment provider "${order.paymentMethod}" is not configured`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof adapter.refund !== 'function') {
|
|
71
|
+
throw new RefundError('refund_unsupported', `Payment provider "${adapter.id}" does not support refunds`);
|
|
72
|
+
}
|
|
73
|
+
const alreadyRefunded = await getRefundedAmount(order.id);
|
|
74
|
+
const remaining = order.totalGross - alreadyRefunded;
|
|
75
|
+
if (remaining <= 0) {
|
|
76
|
+
throw new RefundError('amount_exceeds_remaining', `Order ${order.number} already fully refunded`);
|
|
77
|
+
}
|
|
78
|
+
const amount = input.amount ?? remaining;
|
|
79
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
80
|
+
throw new RefundError('invalid_amount', `Refund amount must be a positive integer`);
|
|
81
|
+
}
|
|
82
|
+
if (amount > remaining) {
|
|
83
|
+
throw new RefundError('amount_exceeds_remaining', `Refund amount ${amount} exceeds remaining ${remaining}`);
|
|
84
|
+
}
|
|
85
|
+
const [pending] = await db
|
|
86
|
+
.insert(shopRefundsTable)
|
|
87
|
+
.values({
|
|
88
|
+
orderId: order.id,
|
|
89
|
+
provider: adapter.id,
|
|
90
|
+
amount,
|
|
91
|
+
currency: order.currency,
|
|
92
|
+
reason: input.reason ?? null,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
createdBy: input.createdBy ?? null
|
|
95
|
+
})
|
|
96
|
+
.returning();
|
|
97
|
+
let providerRef = null;
|
|
98
|
+
try {
|
|
99
|
+
const providerResult = await adapter.refund({
|
|
100
|
+
providerRef: order.paymentProviderRef,
|
|
101
|
+
amount,
|
|
102
|
+
currency: order.currency,
|
|
103
|
+
reason: input.reason
|
|
104
|
+
});
|
|
105
|
+
providerRef = providerResult.providerRef;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
await db
|
|
109
|
+
.update(shopRefundsTable)
|
|
110
|
+
.set({
|
|
111
|
+
status: 'failed',
|
|
112
|
+
updatedAt: new Date()
|
|
113
|
+
})
|
|
114
|
+
.where(eq(shopRefundsTable.id, pending.id));
|
|
115
|
+
throw new RefundError('provider_error', `Refund failed at provider: ${err.message}`, err);
|
|
116
|
+
}
|
|
117
|
+
const [succeeded] = await db
|
|
118
|
+
.update(shopRefundsTable)
|
|
119
|
+
.set({
|
|
120
|
+
status: 'succeeded',
|
|
121
|
+
providerRef,
|
|
122
|
+
updatedAt: new Date()
|
|
123
|
+
})
|
|
124
|
+
.where(eq(shopRefundsTable.id, pending.id))
|
|
125
|
+
.returning();
|
|
126
|
+
const newRemaining = remaining - amount;
|
|
127
|
+
let orderStatusChanged = false;
|
|
128
|
+
if (newRemaining === 0) {
|
|
129
|
+
await updateOrderStatus(order.id, 'refunded', {
|
|
130
|
+
note: `Refund ${amount}${input.reason ? ` — ${input.reason}` : ''}`,
|
|
131
|
+
changedBy: input.createdBy ?? 'admin'
|
|
132
|
+
});
|
|
133
|
+
orderStatusChanged = true;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
refund: succeeded,
|
|
137
|
+
remainingRefundable: newRemaining,
|
|
138
|
+
orderStatusChanged
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -122,11 +122,9 @@
|
|
|
122
122
|
},
|
|
123
123
|
{ once: true }
|
|
124
124
|
);
|
|
125
|
-
s.addEventListener(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
{ once: true }
|
|
129
|
-
);
|
|
125
|
+
s.addEventListener('error', () => reject(new Error(`Geowidget script failed: ${src}`)), {
|
|
126
|
+
once: true
|
|
127
|
+
});
|
|
130
128
|
document.head.appendChild(s);
|
|
131
129
|
});
|
|
132
130
|
}
|
|
@@ -138,8 +136,7 @@
|
|
|
138
136
|
// `onpoint` attribute is the *name of a custom event* that Geowidget
|
|
139
137
|
// dispatches on `document` after a user picks a point. Use a unique name
|
|
140
138
|
// per instance so multiple pickers don't fight over the same listener.
|
|
141
|
-
pointEventName =
|
|
142
|
-
'aria-inpost-pick-' + Math.random().toString(36).slice(2, 10);
|
|
139
|
+
pointEventName = 'aria-inpost-pick-' + Math.random().toString(36).slice(2, 10);
|
|
143
140
|
|
|
144
141
|
const el = document.createElement('inpost-geowidget');
|
|
145
142
|
el.setAttribute('token', d.widget.config.token);
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
|
-
import {
|
|
4
|
-
createOrderState,
|
|
5
|
-
type OrderDetailResponse,
|
|
6
|
-
type OrderState
|
|
7
|
-
} from '../client/index.js';
|
|
3
|
+
import { createOrderState, type OrderDetailResponse, type OrderState } from '../client/index.js';
|
|
8
4
|
import { DEFAULT_LABELS_PL, type OrderStatusLabels } from './labels.js';
|
|
9
5
|
|
|
10
6
|
interface Props {
|
|
@@ -83,7 +79,9 @@
|
|
|
83
79
|
return d.toLocaleString('pl-PL');
|
|
84
80
|
}
|
|
85
81
|
|
|
86
|
-
function productName(
|
|
82
|
+
function productName(
|
|
83
|
+
snapshot: { product?: string; variant?: string } | null | undefined
|
|
84
|
+
): string {
|
|
87
85
|
if (!snapshot) return '—';
|
|
88
86
|
const product = snapshot.product ?? '';
|
|
89
87
|
const variant = snapshot.variant;
|
|
@@ -91,12 +89,10 @@
|
|
|
91
89
|
}
|
|
92
90
|
|
|
93
91
|
const canRetry = $derived(
|
|
94
|
-
order.data?.order.status === 'awaitingPayment' ||
|
|
95
|
-
order.data?.order.status === 'paymentRejected'
|
|
92
|
+
order.data?.order.status === 'awaitingPayment' || order.data?.order.status === 'paymentRejected'
|
|
96
93
|
);
|
|
97
94
|
const isErrorState = $derived(
|
|
98
|
-
order.data?.order.status === 'paymentRejected' ||
|
|
99
|
-
order.data?.order.status === 'cancelled'
|
|
95
|
+
order.data?.order.status === 'paymentRejected' || order.data?.order.status === 'cancelled'
|
|
100
96
|
);
|
|
101
97
|
const isSuccessState = $derived(
|
|
102
98
|
order.data?.order.status === 'paid' ||
|
|
@@ -7,7 +7,8 @@ export const DEFAULT_LABELS_PL = {
|
|
|
7
7
|
sent: 'Zamówienie wysłane',
|
|
8
8
|
done: 'Zamówienie zrealizowane',
|
|
9
9
|
cancelled: 'Zamówienie anulowane',
|
|
10
|
-
paymentRejected: 'Płatność nieudana'
|
|
10
|
+
paymentRejected: 'Płatność nieudana',
|
|
11
|
+
refunded: 'Zamówienie zwrócone'
|
|
11
12
|
},
|
|
12
13
|
intro: {
|
|
13
14
|
new: 'Otrzymaliśmy Twoje zamówienie.',
|
|
@@ -17,7 +18,8 @@ export const DEFAULT_LABELS_PL = {
|
|
|
17
18
|
sent: 'Zamówienie zostało wysłane.',
|
|
18
19
|
done: 'Zamówienie zostało zakończone. Dziękujemy za zakupy!',
|
|
19
20
|
cancelled: 'To zamówienie zostało anulowane.',
|
|
20
|
-
paymentRejected: 'Płatność nie doszła do skutku. Możesz spróbować ponownie.'
|
|
21
|
+
paymentRejected: 'Płatność nie doszła do skutku. Możesz spróbować ponownie.',
|
|
22
|
+
refunded: 'Środki zostały zwrócone na Twoje konto.'
|
|
21
23
|
},
|
|
22
24
|
orderNumber: 'Numer zamówienia',
|
|
23
25
|
items: 'Pozycje',
|