includio-cms 0.15.0 → 0.15.2

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 (83) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/DOCS.md +231 -1
  3. package/ROADMAP.md +7 -2
  4. package/dist/admin/client/shop/shipping-method-edit-page.svelte +1 -0
  5. package/dist/admin/client/shop/shipping-method-form.svelte +89 -21
  6. package/dist/admin/client/shop/shipping-method-form.svelte.d.ts +8 -1
  7. package/dist/admin/client/shop/shipping-method-new-page.svelte +1 -0
  8. package/dist/admin/client/shop/shipping-methods-list-page.svelte +7 -4
  9. package/dist/admin/client/shop/shop-products-list-page.svelte +2 -2
  10. package/dist/admin/components/fields/shop-field.svelte +63 -22
  11. package/dist/admin/remote/shop.remote.d.ts +16 -56
  12. package/dist/admin/remote/shop.remote.js +6 -4
  13. package/dist/cli/scaffold/admin.js +32 -0
  14. package/dist/db-postgres/schema/shop/order.d.ts +34 -0
  15. package/dist/db-postgres/schema/shop/order.js +2 -0
  16. package/dist/db-postgres/schema/shop/product.d.ts +4 -4
  17. package/dist/db-postgres/schema/shop/product.js +3 -2
  18. package/dist/db-postgres/schema/shop/productVariant.d.ts +4 -4
  19. package/dist/db-postgres/schema/shop/productVariant.js +3 -2
  20. package/dist/db-postgres/schema/shop/shippingMethod.d.ts +23 -4
  21. package/dist/db-postgres/schema/shop/shippingMethod.js +4 -2
  22. package/dist/shop/adapters/payu/client.d.ts +22 -0
  23. package/dist/shop/adapters/payu/client.js +78 -0
  24. package/dist/shop/adapters/payu/index.d.ts +24 -0
  25. package/dist/shop/adapters/payu/index.js +88 -0
  26. package/dist/shop/adapters/payu/payload.d.ts +48 -0
  27. package/dist/shop/adapters/payu/payload.js +48 -0
  28. package/dist/shop/adapters/payu/signature.d.ts +12 -0
  29. package/dist/shop/adapters/payu/signature.js +50 -0
  30. package/dist/shop/adapters/payu/status-map.d.ts +3 -0
  31. package/dist/shop/adapters/payu/status-map.js +14 -0
  32. package/dist/shop/cart/order-token-cookie.d.ts +9 -0
  33. package/dist/shop/cart/order-token-cookie.js +40 -0
  34. package/dist/shop/client/index.d.ts +64 -1
  35. package/dist/shop/client/index.js +9 -0
  36. package/dist/shop/client/use-order.svelte.d.ts +32 -0
  37. package/dist/shop/client/use-order.svelte.js +105 -0
  38. package/dist/shop/http/checkout-handler.js +47 -4
  39. package/dist/shop/http/index.d.ts +4 -0
  40. package/dist/shop/http/index.js +4 -0
  41. package/dist/shop/http/order-handler.d.ts +4 -0
  42. package/dist/shop/http/order-handler.js +85 -0
  43. package/dist/shop/http/refresh-payment-handler.d.ts +4 -0
  44. package/dist/shop/http/refresh-payment-handler.js +73 -0
  45. package/dist/shop/http/retry-payment-handler.d.ts +4 -0
  46. package/dist/shop/http/retry-payment-handler.js +99 -0
  47. package/dist/shop/http/retry-payment-logic.d.ts +2 -0
  48. package/dist/shop/http/retry-payment-logic.js +4 -0
  49. package/dist/shop/http/shipping-handler.js +2 -1
  50. package/dist/shop/http/webhook-handler.d.ts +4 -0
  51. package/dist/shop/http/webhook-handler.js +73 -0
  52. package/dist/shop/http/webhook-logic.d.ts +4 -0
  53. package/dist/shop/http/webhook-logic.js +21 -0
  54. package/dist/shop/index.d.ts +3 -1
  55. package/dist/shop/index.js +3 -1
  56. package/dist/shop/pricing.d.ts +4 -0
  57. package/dist/shop/pricing.js +18 -0
  58. package/dist/shop/server/cart-hydrate.js +6 -3
  59. package/dist/shop/server/email.js +18 -2
  60. package/dist/shop/server/order-access-url.d.ts +7 -0
  61. package/dist/shop/server/order-access-url.js +6 -0
  62. package/dist/shop/server/orders.d.ts +1 -0
  63. package/dist/shop/server/orders.js +12 -0
  64. package/dist/shop/server/payment-compat.d.ts +5 -0
  65. package/dist/shop/server/payment-compat.js +9 -0
  66. package/dist/shop/server/populate.d.ts +2 -0
  67. package/dist/shop/server/shipping.d.ts +12 -4
  68. package/dist/shop/server/shipping.js +24 -14
  69. package/dist/shop/server/shop-data.d.ts +8 -2
  70. package/dist/shop/server/shop-data.js +18 -10
  71. package/dist/shop/svelte/OrderStatus.svelte +368 -0
  72. package/dist/shop/svelte/OrderStatus.svelte.d.ts +14 -0
  73. package/dist/shop/svelte/index.d.ts +3 -0
  74. package/dist/shop/svelte/index.js +2 -0
  75. package/dist/shop/svelte/labels.d.ts +25 -0
  76. package/dist/shop/svelte/labels.js +41 -0
  77. package/dist/shop/types.d.ts +19 -1
  78. package/dist/updates/0.15.1/index.d.ts +2 -0
  79. package/dist/updates/0.15.1/index.js +27 -0
  80. package/dist/updates/0.15.2/index.d.ts +2 -0
  81. package/dist/updates/0.15.2/index.js +18 -0
  82. package/dist/updates/index.js +3 -1
  83. package/package.json +5 -1
@@ -1,8 +1,10 @@
1
1
  import { json } from '@sveltejs/kit';
2
2
  import { getCMS } from '../../core/cms.js';
3
3
  import { readCartCookie, writeCartCookie } from '../cart/cookie.js';
4
- import { createOrderFromCart } from '../server/orders.js';
4
+ import { writeOrderTokenCookie } from '../cart/order-token-cookie.js';
5
+ import { createOrderFromCart, setPaymentProviderRef } from '../server/orders.js';
5
6
  import { checkRateLimit, clientKey } from '../rate-limit.js';
7
+ import { requireShopConfig } from '../server/db.js';
6
8
  function shopEnabled() {
7
9
  try {
8
10
  return getCMS().shopConfig !== null;
@@ -41,7 +43,7 @@ function asConsents(v) {
41
43
  }
42
44
  export function createCheckoutHandler() {
43
45
  return {
44
- POST: async ({ request, cookies }) => {
46
+ POST: async ({ request, cookies, getClientAddress }) => {
45
47
  if (!shopEnabled())
46
48
  return json({ error: 'Shop not enabled' }, { status: 404 });
47
49
  const rule = getCMS().shopConfig?.rateLimit.checkout ?? { limit: 5, windowSec: 60 };
@@ -81,14 +83,55 @@ export function createCheckoutHandler() {
81
83
  });
82
84
  // Clear cart on successful order
83
85
  writeCartCookie(cookies, []);
86
+ // Short-lived cookie for same-browser order view fallback
87
+ writeOrderTokenCookie(cookies, {
88
+ number: result.order.number,
89
+ token: result.order.accessToken
90
+ });
91
+ // Initiate payment (if adapter supports redirect)
92
+ const shop = requireShopConfig();
93
+ const adapter = shop.payment.find((a) => a.id === paymentMethod);
94
+ let paymentResult = null;
95
+ if (adapter) {
96
+ const orderRef = {
97
+ id: result.order.id,
98
+ number: result.order.number,
99
+ totalGross: result.order.totalGross,
100
+ currency: shop.currency,
101
+ customerEmail: result.order.customerEmail
102
+ };
103
+ let customerIp;
104
+ try {
105
+ customerIp = getClientAddress();
106
+ }
107
+ catch {
108
+ customerIp = undefined;
109
+ }
110
+ try {
111
+ paymentResult = await adapter.createPayment(orderRef, {
112
+ customerIp,
113
+ language: result.order.language
114
+ });
115
+ if (paymentResult.providerRef) {
116
+ await setPaymentProviderRef(result.order.id, paymentResult.providerRef);
117
+ }
118
+ }
119
+ catch (err) {
120
+ console.error(`[shop] createPayment failed for ${result.order.number}:`, err);
121
+ paymentResult = { status: 'error' };
122
+ }
123
+ }
124
+ const requiresRedirect = paymentResult?.status === 'redirect';
84
125
  return json({
85
126
  orderId: result.order.id,
86
127
  orderNumber: result.order.number,
128
+ accessToken: result.order.accessToken,
87
129
  status: result.order.status,
88
130
  totalGross: result.order.totalGross,
89
131
  currency: result.order.currency,
90
- requiresPaymentRedirect: result.requiresPaymentRedirect,
91
- redirectUrl: result.redirectUrl ?? null
132
+ paymentStatus: paymentResult?.status ?? 'manual',
133
+ requiresPaymentRedirect: requiresRedirect,
134
+ redirectUrl: requiresRedirect ? paymentResult?.redirectUrl ?? null : null
92
135
  });
93
136
  }
94
137
  catch (err) {
@@ -1,3 +1,7 @@
1
1
  export { createCartHandler } from './cart-handler.js';
2
2
  export { createShippingMethodsHandler } from './shipping-handler.js';
3
3
  export { createCheckoutHandler } from './checkout-handler.js';
4
+ export { createOrderHandler } from './order-handler.js';
5
+ export { createPaymentWebhookHandler } from './webhook-handler.js';
6
+ export { createRefreshPaymentHandler } from './refresh-payment-handler.js';
7
+ export { createRetryPaymentHandler } from './retry-payment-handler.js';
@@ -1,3 +1,7 @@
1
1
  export { createCartHandler } from './cart-handler.js';
2
2
  export { createShippingMethodsHandler } from './shipping-handler.js';
3
3
  export { createCheckoutHandler } from './checkout-handler.js';
4
+ export { createOrderHandler } from './order-handler.js';
5
+ export { createPaymentWebhookHandler } from './webhook-handler.js';
6
+ export { createRefreshPaymentHandler } from './refresh-payment-handler.js';
7
+ export { createRetryPaymentHandler } from './retry-payment-handler.js';
@@ -0,0 +1,4 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare function createOrderHandler(): {
3
+ GET: RequestHandler;
4
+ };
@@ -0,0 +1,85 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { getCMS } from '../../core/cms.js';
4
+ import { getOrderByNumber, getOrderItems, getOrderStatusHistory } from '../server/orders.js';
5
+ import { readOrderTokenCookie } from '../cart/order-token-cookie.js';
6
+ function shopEnabled() {
7
+ try {
8
+ return getCMS().shopConfig !== null;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function tokensMatch(a, b) {
15
+ if (a.length !== b.length)
16
+ return false;
17
+ try {
18
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export function createOrderHandler() {
25
+ return {
26
+ GET: async ({ params, url, cookies }) => {
27
+ if (!shopEnabled())
28
+ return json({ error: 'Shop not enabled' }, { status: 404 });
29
+ const number = params.number;
30
+ if (!number)
31
+ return json({ error: 'Order number required' }, { status: 400 });
32
+ const order = await getOrderByNumber(number);
33
+ if (!order)
34
+ return json({ error: 'Not found' }, { status: 404 });
35
+ const queryToken = url.searchParams.get('token') ?? '';
36
+ const cookieValue = readOrderTokenCookie(cookies);
37
+ const cookieToken = cookieValue && cookieValue.number === order.number ? cookieValue.token : '';
38
+ const providedToken = queryToken || cookieToken;
39
+ if (!providedToken || !tokensMatch(providedToken, order.accessToken)) {
40
+ return json({ error: 'Forbidden' }, { status: 403 });
41
+ }
42
+ const [items, history] = await Promise.all([
43
+ getOrderItems(order.id),
44
+ getOrderStatusHistory(order.id)
45
+ ]);
46
+ return json({
47
+ order: {
48
+ id: order.id,
49
+ number: order.number,
50
+ status: order.status,
51
+ currency: order.currency,
52
+ customerEmail: order.customerEmail,
53
+ customerName: order.customerName,
54
+ customerPhone: order.customerPhone,
55
+ shippingAddress: order.shippingAddress,
56
+ totalNet: order.totalNet,
57
+ totalGross: order.totalGross,
58
+ vatAmount: order.vatAmount,
59
+ shippingNet: order.shippingNet,
60
+ shippingGross: order.shippingGross,
61
+ paymentMethod: order.paymentMethod,
62
+ carrierType: order.carrierType,
63
+ carrierRef: order.carrierRef,
64
+ language: order.language,
65
+ createdAt: order.createdAt
66
+ },
67
+ items: items.map((i) => ({
68
+ id: i.id,
69
+ variantId: i.variantId,
70
+ nameSnapshot: i.nameSnapshot,
71
+ skuSnapshot: i.skuSnapshot,
72
+ priceNetSnapshot: i.priceNetSnapshot,
73
+ priceGrossSnapshot: i.priceGrossSnapshot,
74
+ vatRate: i.vatRate,
75
+ qty: i.qty
76
+ })),
77
+ statusHistory: history.map((h) => ({
78
+ status: h.status,
79
+ note: h.note,
80
+ changedAt: h.changedAt
81
+ }))
82
+ });
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,4 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare function createRefreshPaymentHandler(): {
3
+ POST: RequestHandler;
4
+ };
@@ -0,0 +1,73 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { getCMS } from '../../core/cms.js';
4
+ import { requireShopConfig } from '../server/db.js';
5
+ import { getOrderByNumber, updateOrderStatus } from '../server/orders.js';
6
+ import { readOrderTokenCookie } from '../cart/order-token-cookie.js';
7
+ import { isTerminalStatus, mapEventToStatus } from './webhook-logic.js';
8
+ function shopEnabled() {
9
+ try {
10
+ return getCMS().shopConfig !== null;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function tokensMatch(a, b) {
17
+ if (a.length !== b.length)
18
+ return false;
19
+ try {
20
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export function createRefreshPaymentHandler() {
27
+ return {
28
+ POST: async ({ params, url, cookies }) => {
29
+ if (!shopEnabled())
30
+ return json({ error: 'Shop not enabled' }, { status: 404 });
31
+ const number = params.number;
32
+ if (!number)
33
+ return json({ error: 'Order number required' }, { status: 400 });
34
+ const order = await getOrderByNumber(number);
35
+ if (!order)
36
+ return json({ error: 'Not found' }, { status: 404 });
37
+ const queryToken = url.searchParams.get('token') ?? '';
38
+ const cookieValue = readOrderTokenCookie(cookies);
39
+ const cookieToken = cookieValue && cookieValue.number === order.number ? cookieValue.token : '';
40
+ const providedToken = queryToken || cookieToken;
41
+ if (!providedToken || !tokensMatch(providedToken, order.accessToken)) {
42
+ return json({ error: 'Forbidden' }, { status: 403 });
43
+ }
44
+ if (isTerminalStatus(order.status)) {
45
+ return json({ status: order.status, noop: true });
46
+ }
47
+ if (!order.paymentMethod || !order.paymentProviderRef) {
48
+ return json({ status: order.status, noop: true });
49
+ }
50
+ const shop = requireShopConfig();
51
+ const adapter = shop.payment.find((a) => a.id === order.paymentMethod);
52
+ if (!adapter || typeof adapter.getStatus !== 'function') {
53
+ return json({ status: order.status, noop: true });
54
+ }
55
+ try {
56
+ const event = await adapter.getStatus(order.paymentProviderRef);
57
+ const target = mapEventToStatus(event);
58
+ if (target && target !== order.status) {
59
+ await updateOrderStatus(order.id, target, {
60
+ note: `Refresh-payment (${order.paymentMethod})`,
61
+ changedBy: 'refresh-payment'
62
+ });
63
+ return json({ status: target, updated: true });
64
+ }
65
+ return json({ status: order.status, updated: false });
66
+ }
67
+ catch (err) {
68
+ console.error(`[shop] refresh-payment failed for ${order.number}:`, err);
69
+ return json({ error: 'Refresh failed' }, { status: 502 });
70
+ }
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,4 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare function createRetryPaymentHandler(): {
3
+ POST: RequestHandler;
4
+ };
@@ -0,0 +1,99 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { getCMS } from '../../core/cms.js';
4
+ import { requireShopConfig } from '../server/db.js';
5
+ import { getOrderByNumber, setPaymentProviderRef, updateOrderStatus } from '../server/orders.js';
6
+ import { readOrderTokenCookie } from '../cart/order-token-cookie.js';
7
+ import { canRetryPayment } from './retry-payment-logic.js';
8
+ function shopEnabled() {
9
+ try {
10
+ return getCMS().shopConfig !== null;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function tokensMatch(a, b) {
17
+ if (a.length !== b.length)
18
+ return false;
19
+ try {
20
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export function createRetryPaymentHandler() {
27
+ return {
28
+ POST: async ({ params, url, cookies, getClientAddress }) => {
29
+ if (!shopEnabled())
30
+ return json({ error: 'Shop not enabled' }, { status: 404 });
31
+ const number = params.number;
32
+ if (!number)
33
+ return json({ error: 'Order number required' }, { status: 400 });
34
+ const order = await getOrderByNumber(number);
35
+ if (!order)
36
+ return json({ error: 'Not found' }, { status: 404 });
37
+ const queryToken = url.searchParams.get('token') ?? '';
38
+ const cookieValue = readOrderTokenCookie(cookies);
39
+ const cookieToken = cookieValue && cookieValue.number === order.number ? cookieValue.token : '';
40
+ const providedToken = queryToken || cookieToken;
41
+ if (!providedToken || !tokensMatch(providedToken, order.accessToken)) {
42
+ return json({ error: 'Forbidden' }, { status: 403 });
43
+ }
44
+ if (!canRetryPayment(order.status)) {
45
+ return json({ error: `Order status "${order.status}" does not allow retry`, status: order.status }, { status: 409 });
46
+ }
47
+ if (!order.paymentMethod) {
48
+ return json({ error: 'Order has no payment method' }, { status: 400 });
49
+ }
50
+ const shop = requireShopConfig();
51
+ const adapter = shop.payment.find((a) => a.id === order.paymentMethod);
52
+ if (!adapter) {
53
+ return json({ error: 'Payment adapter not available' }, { status: 400 });
54
+ }
55
+ const orderRef = {
56
+ id: order.id,
57
+ number: order.number,
58
+ totalGross: order.totalGross,
59
+ currency: shop.currency,
60
+ customerEmail: order.customerEmail
61
+ };
62
+ let customerIp;
63
+ try {
64
+ customerIp = getClientAddress();
65
+ }
66
+ catch {
67
+ customerIp = undefined;
68
+ }
69
+ let result;
70
+ try {
71
+ result = await adapter.createPayment(orderRef, {
72
+ customerIp,
73
+ language: order.language
74
+ });
75
+ }
76
+ catch (err) {
77
+ console.error(`[shop] retry-payment failed for ${order.number}:`, err);
78
+ return json({ error: 'Payment creation failed' }, { status: 502 });
79
+ }
80
+ if (result.providerRef) {
81
+ await setPaymentProviderRef(order.id, result.providerRef);
82
+ }
83
+ // If the order was paymentRejected, move it back to awaitingPayment so the
84
+ // new attempt can transition it via webhook/polling.
85
+ if (order.status === 'paymentRejected') {
86
+ await updateOrderStatus(order.id, 'awaitingPayment', {
87
+ note: 'Retry payment',
88
+ changedBy: 'retry-payment'
89
+ });
90
+ }
91
+ return json({
92
+ status: result.status === 'redirect' ? 'awaitingPayment' : order.status,
93
+ paymentStatus: result.status,
94
+ requiresPaymentRedirect: result.status === 'redirect',
95
+ redirectUrl: result.status === 'redirect' ? result.redirectUrl ?? null : null
96
+ });
97
+ }
98
+ };
99
+ }
@@ -0,0 +1,2 @@
1
+ import type { OrderStatus } from '../types.js';
2
+ export declare function canRetryPayment(status: OrderStatus): boolean;
@@ -0,0 +1,4 @@
1
+ const RETRYABLE = new Set(['awaitingPayment', 'paymentRejected']);
2
+ export function canRetryPayment(status) {
3
+ return RETRYABLE.has(status);
4
+ }
@@ -23,7 +23,8 @@ export function createShippingMethodsHandler() {
23
23
  price: m.price,
24
24
  vatRate: m.vatRate,
25
25
  carrierType: m.carrierType,
26
- conditions: m.conditions
26
+ conditions: m.conditions,
27
+ allowedPaymentMethods: m.allowedPaymentMethods ?? null
27
28
  }))
28
29
  });
29
30
  }
@@ -0,0 +1,4 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare function createPaymentWebhookHandler(): {
3
+ POST: RequestHandler;
4
+ };
@@ -0,0 +1,73 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import { requireShopConfig } from '../server/db.js';
4
+ import { getOrderByNumber, updateOrderStatus } from '../server/orders.js';
5
+ import { checkRateLimit, clientKey } from '../rate-limit.js';
6
+ import { isTerminalStatus, mapEventToStatus } from './webhook-logic.js';
7
+ function shopEnabled() {
8
+ try {
9
+ return getCMS().shopConfig !== null;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ function findAdapter(provider) {
16
+ const shop = requireShopConfig();
17
+ return shop.payment.find((a) => a.id === provider) ?? null;
18
+ }
19
+ export function createPaymentWebhookHandler() {
20
+ return {
21
+ POST: async ({ params, request }) => {
22
+ if (!shopEnabled())
23
+ return json({ error: 'Shop not enabled' }, { status: 404 });
24
+ const provider = params.provider;
25
+ if (!provider)
26
+ return json({ error: 'Provider required' }, { status: 400 });
27
+ const rule = getCMS().shopConfig?.rateLimit.webhook ?? { limit: 60, windowSec: 60 };
28
+ const rl = checkRateLimit(clientKey(request, `webhook:${provider}`), rule);
29
+ if (!rl.allowed) {
30
+ return json({ error: 'Rate limit exceeded' }, { status: 429 });
31
+ }
32
+ const adapter = findAdapter(provider);
33
+ if (!adapter || typeof adapter.handleWebhook !== 'function') {
34
+ return json({ error: 'Unknown provider' }, { status: 404 });
35
+ }
36
+ let event;
37
+ try {
38
+ event = await adapter.handleWebhook(request);
39
+ }
40
+ catch (err) {
41
+ console.error(`[shop] Webhook parse/verify failed for ${provider}:`, err);
42
+ // Bad signature / malformed body → 400 (don't encourage retries with 200)
43
+ return json({ error: 'Invalid webhook' }, { status: 400 });
44
+ }
45
+ const order = await getOrderByNumber(event.orderNumber);
46
+ if (!order) {
47
+ // Unknown order — 200 so the provider stops retrying
48
+ console.warn(`[shop] Webhook for unknown order ${event.orderNumber} (${provider}) — acking.`);
49
+ return json({ received: true });
50
+ }
51
+ // Idempotency — terminal states are no-ops
52
+ if (isTerminalStatus(order.status)) {
53
+ return json({ received: true, idempotent: true });
54
+ }
55
+ const targetStatus = mapEventToStatus(event);
56
+ if (!targetStatus) {
57
+ return json({ received: true, noop: true });
58
+ }
59
+ try {
60
+ await updateOrderStatus(order.id, targetStatus, {
61
+ note: `Payment webhook (${provider})`,
62
+ changedBy: 'payment-webhook'
63
+ });
64
+ }
65
+ catch (err) {
66
+ console.error(`[shop] updateOrderStatus failed for ${order.number}:`, err);
67
+ // Return 500 — provider retry helps
68
+ return json({ error: 'Processing failed' }, { status: 500 });
69
+ }
70
+ return json({ received: true });
71
+ }
72
+ };
73
+ }
@@ -0,0 +1,4 @@
1
+ import type { OrderStatus, PaymentEvent } from '../types.js';
2
+ export declare const TERMINAL_ORDER_STATUSES: ReadonlySet<OrderStatus>;
3
+ export declare function isTerminalStatus(status: OrderStatus): boolean;
4
+ export declare function mapEventToStatus(event: PaymentEvent): OrderStatus | null;
@@ -0,0 +1,21 @@
1
+ export const TERMINAL_ORDER_STATUSES = new Set([
2
+ 'paid',
3
+ 'paymentRejected',
4
+ 'cancelled',
5
+ 'done'
6
+ ]);
7
+ export function isTerminalStatus(status) {
8
+ return TERMINAL_ORDER_STATUSES.has(status);
9
+ }
10
+ export function mapEventToStatus(event) {
11
+ switch (event.status) {
12
+ case 'paid':
13
+ return 'paid';
14
+ case 'paymentRejected':
15
+ return 'paymentRejected';
16
+ case 'pending':
17
+ return null;
18
+ default:
19
+ return null;
20
+ }
21
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ShopConfig, ResolvedShopConfig } from './types.js';
2
2
  export declare function defineShop(config: ShopConfig): ResolvedShopConfig;
3
3
  export { manualAdapter } from './adapters/manual/index.js';
4
- export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, CarrierAdapter, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, I18nText } from './types.js';
4
+ export { payuAdapter } from './adapters/payu/index.js';
5
+ export type { PayuAdapterOptions } from './adapters/payu/index.js';
6
+ export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, CarrierAdapter, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, I18nText } from './types.js';
@@ -11,7 +11,9 @@ export function defineShop(config) {
11
11
  webhook: config.rateLimit?.webhook ?? { limit: 60, windowSec: 60 }
12
12
  },
13
13
  carriers: config.carriers ?? [],
14
- consents: config.consents ?? []
14
+ consents: config.consents ?? [],
15
+ orderViewUrl: config.orderViewUrl ?? '/shop/order/{orderNumber}?token={accessToken}'
15
16
  };
16
17
  }
17
18
  export { manualAdapter } from './adapters/manual/index.js';
19
+ export { payuAdapter } from './adapters/payu/index.js';
@@ -1,3 +1,7 @@
1
+ export declare function netPlnFromGrossPln(grossPln: number, vatRate: number): number;
2
+ export declare function grossPlnFromNetPln(netPln: number, vatRate: number): number;
3
+ export declare function toCents(pln: number): number;
4
+ export declare function fromCents(cents: number): number;
1
5
  export declare function grossFromNet(net: number, vatRate: number): number;
2
6
  export declare function netFromGross(gross: number, vatRate: number): number;
3
7
  export declare function vatAmount(net: number, vatRate: number): number;
@@ -1,3 +1,21 @@
1
+ // Pricing — dwie domeny:
2
+ // PLN-precise (number, do 6dp) — używane dla basePrice/priceDelta/shipping.price w DB (numeric 20,6).
3
+ // cents (integer, grosze) — używane w snapshotach zamówienia/faktury (KSeF).
4
+ // Round do groszy odbywa się RAZ, na granicy PLN→cents, po kompletnym mnożeniu netto×(1+VAT).
5
+ export function netPlnFromGrossPln(grossPln, vatRate) {
6
+ return grossPln / (1 + vatRate / 100);
7
+ }
8
+ export function grossPlnFromNetPln(netPln, vatRate) {
9
+ return netPln * (1 + vatRate / 100);
10
+ }
11
+ export function toCents(pln) {
12
+ return Math.round(pln * 100);
13
+ }
14
+ export function fromCents(cents) {
15
+ return cents / 100;
16
+ }
17
+ // Compat shims — `net`/`gross` w CENTACH. Deprecated; nie używać w nowych ścieżkach.
18
+ // Cierpią na drift dla input-as-gross + round-trip. Zostawione dla testów i zewnętrznych konsumentów.
1
19
  export function grossFromNet(net, vatRate) {
2
20
  return Math.round(net * (1 + vatRate / 100));
3
21
  }
@@ -4,7 +4,7 @@ import { entriesTable } from '../../db-postgres/schema/entry.js';
4
4
  import { entryVersionsTable } from '../../db-postgres/schema/entryVersion.js';
5
5
  import { getCMS } from '../../core/cms.js';
6
6
  import { getShopDb, requireShopConfig } from './db.js';
7
- import { grossFromNet } from '../pricing.js';
7
+ import { grossPlnFromNetPln, toCents } from '../pricing.js';
8
8
  export async function hydrateCart(items, opts = {}) {
9
9
  const shop = requireShopConfig();
10
10
  const cms = getCMS();
@@ -94,8 +94,11 @@ export async function hydrateCart(items, opts = {}) {
94
94
  const version = publishedByEntry.get(product.entryId);
95
95
  const title = readTitle(version?.data);
96
96
  const slug = readSlug(version?.data);
97
- const priceNet = product.basePrice + (variant.priceDelta ?? 0);
98
- const priceGross = grossFromNet(priceNet, product.vatRate);
97
+ // basePrice/priceDelta numeric(20,6) drizzle zwraca string. Konwertujemy do PLN (number).
98
+ const priceNetPln = Number(product.basePrice) + Number(variant.priceDelta ?? 0);
99
+ const priceGrossPln = grossPlnFromNetPln(priceNetPln, product.vatRate);
100
+ const priceNet = toCents(priceNetPln);
101
+ const priceGross = toCents(priceGrossPln);
99
102
  const stock = variant.stock;
100
103
  const stockEnabled = shop.features.stock;
101
104
  let effectiveQty = ref.qty;
@@ -2,6 +2,7 @@ import { getCMS } from '../../core/cms.js';
2
2
  import { resolveI18n } from '../pricing.js';
3
3
  import { getOrderById, getOrderItems } from './orders.js';
4
4
  import { requireShopConfig } from './db.js';
5
+ import { buildOrderViewUrl } from './order-access-url.js';
5
6
  function formatPrice(cents, currency) {
6
7
  return new Intl.NumberFormat('pl-PL', {
7
8
  style: 'currency',
@@ -53,6 +54,10 @@ const STATUS_INTRO = {
53
54
  en: 'Payment was not received. Please contact us if you have questions.'
54
55
  }
55
56
  };
57
+ const VIEW_LINK_LABEL = {
58
+ pl: 'Zobacz zamówienie',
59
+ en: 'View order'
60
+ };
56
61
  function renderHtml(ctx, intro) {
57
62
  const itemsRows = ctx.items
58
63
  .map((i) => `<tr><td style="padding:6px 8px;border-bottom:1px solid #eee;">${escapeHtml(i.name)}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:center;">${i.qty}</td><td style="padding:6px 8px;border-bottom:1px solid #eee;text-align:right;">${i.lineGross}</td></tr>`)
@@ -72,6 +77,9 @@ function renderHtml(ctx, intro) {
72
77
  <div style="font-size:16px;">Razem (brutto): <strong style="color:#5B4A9E;">${ctx.order.totalGross}</strong></div>
73
78
  <div style="color:#8888A0;font-size:12px;">netto ${ctx.order.totalNet} · VAT ${ctx.order.vatAmount}</div>
74
79
  </div>
80
+ ${ctx.viewUrl
81
+ ? `<div style="margin-top:24px;text-align:center;"><a href="${escapeHtml(ctx.viewUrl)}" style="display:inline-block;background:#5B4A9E;color:#fff;text-decoration:none;padding:10px 18px;border-radius:8px;font-weight:600;">${escapeHtml(ctx.viewLinkLabel)}</a></div>`
82
+ : ''}
75
83
  </div>
76
84
  <p style="text-align:center;color:#8888A0;font-size:12px;margin-top:16px;">AriaCMS · ${escapeHtml(ctx.order.customerEmail)}</p>
77
85
  </div>
@@ -99,7 +107,17 @@ export async function sendOrderStatusEmail(orderId, status) {
99
107
  const items = await getOrderItems(orderId);
100
108
  const lang = (order.language || cms.languages[0] || 'pl');
101
109
  const subjectKey = (lang in STATUS_SUBJECTS[status] ? lang : 'pl');
110
+ const viewUrl = /^https?:\/\//i.test(shop.orderViewUrl)
111
+ ? buildOrderViewUrl(shop.orderViewUrl, {
112
+ orderNumber: order.number,
113
+ orderId: order.id,
114
+ accessToken: order.accessToken,
115
+ language: order.language
116
+ })
117
+ : null;
102
118
  const ctx = {
119
+ viewUrl,
120
+ viewLinkLabel: VIEW_LINK_LABEL[subjectKey],
103
121
  order: {
104
122
  number: order.number,
105
123
  status: order.status,
@@ -133,6 +151,4 @@ export async function sendOrderStatusEmail(orderId, status) {
133
151
  console.error('[shop] Failed to send status email:', err);
134
152
  // Swallow — don't block status change on email failures
135
153
  }
136
- // Silence unused for now
137
- void shop;
138
154
  }
@@ -0,0 +1,7 @@
1
+ export interface OrderUrlContext {
2
+ orderNumber: string;
3
+ orderId: string;
4
+ accessToken: string;
5
+ language?: string | null;
6
+ }
7
+ export declare function buildOrderViewUrl(template: string, ctx: OrderUrlContext): string;
@@ -0,0 +1,6 @@
1
+ export function buildOrderViewUrl(template, ctx) {
2
+ return template.replace(/\{(orderNumber|orderId|accessToken|language)\}/g, (_m, key) => {
3
+ const value = ctx[key];
4
+ return encodeURIComponent(value == null ? '' : String(value));
5
+ });
6
+ }
@@ -32,6 +32,7 @@ export declare function updateOrderStatus(orderId: string, status: OrderStatus,
32
32
  note?: string;
33
33
  changedBy?: string;
34
34
  }): Promise<OrderRow>;
35
+ export declare function setPaymentProviderRef(orderId: string, ref: string | null): Promise<void>;
35
36
  export declare function getOrderById(id: string): Promise<OrderRow | null>;
36
37
  export declare function getOrderByNumber(number: string): Promise<OrderRow | null>;
37
38
  export declare function getOrderItems(orderId: string): Promise<OrderItemRow[]>;