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.
Files changed (90) hide show
  1. package/API.md +29 -6
  2. package/CHANGELOG.md +95 -0
  3. package/DOCS.md +80 -5
  4. package/ROADMAP.md +1 -0
  5. package/dist/admin/client/index.d.ts +3 -0
  6. package/dist/admin/client/index.js +3 -0
  7. package/dist/admin/client/shop/coupon-edit-page.svelte +44 -0
  8. package/dist/admin/client/shop/coupon-edit-page.svelte.d.ts +3 -0
  9. package/dist/admin/client/shop/coupon-form.svelte +170 -0
  10. package/dist/admin/client/shop/coupon-form.svelte.d.ts +18 -0
  11. package/dist/admin/client/shop/coupon-new-page.svelte +25 -0
  12. package/dist/admin/client/shop/coupon-new-page.svelte.d.ts +18 -0
  13. package/dist/admin/client/shop/coupons-list-page.svelte +135 -0
  14. package/dist/admin/client/shop/coupons-list-page.svelte.d.ts +3 -0
  15. package/dist/admin/client/shop/refund-dialog.svelte +161 -0
  16. package/dist/admin/client/shop/refund-dialog.svelte.d.ts +11 -0
  17. package/dist/admin/client/shop/shipping-method-edit-page.svelte +3 -6
  18. package/dist/admin/client/shop/shipping-method-form.svelte +15 -21
  19. package/dist/admin/client/shop/shipping-method-new-page.svelte +3 -6
  20. package/dist/admin/client/shop/shipping-methods-list-page.svelte +6 -6
  21. package/dist/admin/client/shop/shop-order-detail-page.svelte +107 -27
  22. package/dist/admin/client/shop/shop-orders-list-page.svelte +49 -11
  23. package/dist/admin/client/shop/shop-products-list-page.svelte +12 -11
  24. package/dist/admin/components/layout/lang.d.ts +1 -0
  25. package/dist/admin/components/layout/lang.js +4 -2
  26. package/dist/admin/components/layout/layout-renderer.svelte +12 -11
  27. package/dist/admin/components/layout/nav-breadcrumbs.svelte +3 -5
  28. package/dist/admin/components/layout/nav-shop.svelte +3 -1
  29. package/dist/admin/components/layout/nav-user.svelte +6 -4
  30. package/dist/admin/components/layout/site-header.svelte +11 -5
  31. package/dist/admin/remote/shop.remote.d.ts +122 -3
  32. package/dist/admin/remote/shop.remote.js +161 -5
  33. package/dist/db-postgres/schema/shop/couponRedemptions.d.ts +97 -0
  34. package/dist/db-postgres/schema/shop/couponRedemptions.js +21 -0
  35. package/dist/db-postgres/schema/shop/coupons.d.ts +197 -0
  36. package/dist/db-postgres/schema/shop/coupons.js +18 -0
  37. package/dist/db-postgres/schema/shop/index.d.ts +4 -0
  38. package/dist/db-postgres/schema/shop/index.js +4 -0
  39. package/dist/db-postgres/schema/shop/product.d.ts +17 -0
  40. package/dist/db-postgres/schema/shop/product.js +2 -0
  41. package/dist/db-postgres/schema/shop/refunds.d.ts +214 -0
  42. package/dist/db-postgres/schema/shop/refunds.js +21 -0
  43. package/dist/db-postgres/schema/shop/webhookEvents.d.ts +183 -0
  44. package/dist/db-postgres/schema/shop/webhookEvents.js +22 -0
  45. package/dist/shop/adapters/payu/client.d.ts +9 -0
  46. package/dist/shop/adapters/payu/client.js +29 -0
  47. package/dist/shop/adapters/payu/index.js +17 -1
  48. package/dist/shop/adapters/stripe/index.d.ts +64 -0
  49. package/dist/shop/adapters/stripe/index.js +169 -0
  50. package/dist/shop/adapters/stripe/payload.d.ts +38 -0
  51. package/dist/shop/adapters/stripe/payload.js +90 -0
  52. package/dist/shop/adapters/stripe/status-map.d.ts +11 -0
  53. package/dist/shop/adapters/stripe/status-map.js +31 -0
  54. package/dist/shop/cart/coupon-cookie.d.ts +7 -0
  55. package/dist/shop/cart/coupon-cookie.js +32 -0
  56. package/dist/shop/cart/types.d.ts +12 -0
  57. package/dist/shop/client/index.d.ts +118 -0
  58. package/dist/shop/client/index.js +39 -1
  59. package/dist/shop/http/cart-handler.d.ts +8 -0
  60. package/dist/shop/http/cart-handler.js +60 -1
  61. package/dist/shop/http/checkout-handler.js +7 -3
  62. package/dist/shop/http/index.d.ts +1 -1
  63. package/dist/shop/http/index.js +1 -1
  64. package/dist/shop/http/retry-payment-handler.js +1 -1
  65. package/dist/shop/http/webhook-handler.js +19 -1
  66. package/dist/shop/http/webhook-idempotency.d.ts +16 -0
  67. package/dist/shop/http/webhook-idempotency.js +51 -0
  68. package/dist/shop/http/webhook-logic.js +2 -1
  69. package/dist/shop/index.d.ts +3 -1
  70. package/dist/shop/index.js +3 -1
  71. package/dist/shop/pricing.d.ts +15 -0
  72. package/dist/shop/pricing.js +22 -0
  73. package/dist/shop/server/cart-hydrate.d.ts +1 -0
  74. package/dist/shop/server/cart-hydrate.js +58 -10
  75. package/dist/shop/server/coupons.d.ts +53 -0
  76. package/dist/shop/server/coupons.js +117 -0
  77. package/dist/shop/server/email.d.ts +15 -0
  78. package/dist/shop/server/email.js +46 -3
  79. package/dist/shop/server/orders.d.ts +1 -0
  80. package/dist/shop/server/orders.js +120 -54
  81. package/dist/shop/server/refund.d.ts +32 -0
  82. package/dist/shop/server/refund.js +140 -0
  83. package/dist/shop/svelte/InpostPicker.svelte +4 -7
  84. package/dist/shop/svelte/OrderStatus.svelte +6 -10
  85. package/dist/shop/svelte/labels.js +4 -2
  86. package/dist/shop/types.d.ts +41 -1
  87. package/dist/updates/0.25.0/index.d.ts +2 -0
  88. package/dist/updates/0.25.0/index.js +89 -0
  89. package/dist/updates/index.js +64 -1
  90. 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
+ }
@@ -20,6 +20,7 @@ export interface CreateOrderInput {
20
20
  consents?: ConsentInput[];
21
21
  notes?: string;
22
22
  language?: string;
23
+ couponCode?: string;
23
24
  }
24
25
  export interface CreateOrderResult {
25
26
  order: OrderRow;
@@ -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
- const snapshot = await hydrateCart(input.cartItems, { language: input.language });
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
- const [order] = await db
123
- .insert(shopOrdersTable)
124
- .values({
125
- number: orderNumber,
126
- status: initialStatus,
127
- currency: shop.currency,
128
- customerEmail: input.customerEmail,
129
- customerName: input.customerName ?? null,
130
- customerPhone: input.customerPhone ?? null,
131
- shippingAddress: input.shippingAddress ?? null,
132
- totalNet,
133
- totalGross,
134
- vatAmount: totalVat,
135
- shippingNet: shippingResolved.net,
136
- shippingGross: shippingResolved.gross,
137
- shippingMethodId: shippingMethod.id,
138
- carrierType: shippingMethod.carrierType,
139
- carrierRef: input.carrierRef ?? null,
140
- paymentMethod: input.paymentMethod,
141
- consents: consentSnapshot,
142
- notes: input.notes ?? null,
143
- language: input.language ?? cms.languages[0] ?? null
144
- })
145
- .returning();
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
- 'error',
127
- () => reject(new Error(`Geowidget script failed: ${src}`)),
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(snapshot: { product?: string; variant?: string } | null | undefined): string {
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',