includio-cms 0.26.0 → 0.28.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 (128) hide show
  1. package/API.md +58 -2
  2. package/CHANGELOG.md +105 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +8 -0
  5. package/dist/admin/auth-client.d.ts +42 -42
  6. package/dist/admin/client/admin/admin-layout.svelte +12 -2
  7. package/dist/admin/client/admin/admin-layout.svelte.d.ts +2 -1
  8. package/dist/admin/client/collection/data-table.svelte +0 -39
  9. package/dist/admin/client/collection/data-table.svelte.d.ts +0 -2
  10. package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
  11. package/dist/admin/client/shop/refund-dialog.svelte +37 -1
  12. package/dist/admin/client/shop/refund-dialog.svelte.d.ts +3 -0
  13. package/dist/admin/client/shop/shop-order-detail-page.svelte +192 -0
  14. package/dist/admin/components/fields/field-renderer.svelte +6 -1
  15. package/dist/admin/components/fields/icon-field.svelte +86 -0
  16. package/dist/admin/components/fields/icon-field.svelte.d.ts +8 -0
  17. package/dist/admin/components/fields/icon-picker-dialog.svelte +174 -0
  18. package/dist/admin/components/fields/icon-picker-dialog.svelte.d.ts +11 -0
  19. package/dist/admin/components/fields/object-field.svelte +27 -7
  20. package/dist/admin/components/fields/shop-field.svelte +210 -20
  21. package/dist/admin/components/layout/layout-tabs.svelte +1 -0
  22. package/dist/admin/components/variant-form/VariantAttributeRenderer.svelte +109 -0
  23. package/dist/admin/components/variant-form/VariantAttributeRenderer.svelte.d.ts +9 -0
  24. package/dist/admin/helpers/build-icon-set-map.d.ts +8 -0
  25. package/dist/admin/helpers/build-icon-set-map.js +16 -0
  26. package/dist/admin/helpers/index.d.ts +2 -0
  27. package/dist/admin/helpers/index.js +2 -0
  28. package/dist/admin/remote/shop.remote.d.ts +116 -24
  29. package/dist/admin/remote/shop.remote.js +79 -6
  30. package/dist/admin/state/icon-sets.svelte.d.ts +9 -0
  31. package/dist/admin/state/icon-sets.svelte.js +20 -0
  32. package/dist/cli/scaffold/admin.js +2 -2
  33. package/dist/components/ui/checkbox/checkbox.svelte +3 -3
  34. package/dist/core/cms.d.ts +11 -2
  35. package/dist/core/cms.js +29 -0
  36. package/dist/core/fields/fieldSchemaToTs.js +7 -0
  37. package/dist/core/server/generator/fields.d.ts +2 -0
  38. package/dist/core/server/generator/fields.js +34 -1
  39. package/dist/core/server/generator/generator.js +2 -1
  40. package/dist/db-postgres/schema/shop/index.d.ts +1 -0
  41. package/dist/db-postgres/schema/shop/index.js +1 -0
  42. package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
  43. package/dist/db-postgres/schema/shop/invoice.js +27 -0
  44. package/dist/db-postgres/schema/shop/order.d.ts +107 -1
  45. package/dist/db-postgres/schema/shop/order.js +7 -1
  46. package/dist/db-postgres/schema/shop/payment.d.ts +20 -0
  47. package/dist/db-postgres/schema/shop/payment.js +4 -1
  48. package/dist/db-postgres/schema/shop/product.d.ts +20 -0
  49. package/dist/db-postgres/schema/shop/product.js +3 -1
  50. package/dist/db-postgres/schema/shop/productVariant.d.ts +12 -2
  51. package/dist/db-postgres/schema/shop/productVariant.js +22 -0
  52. package/dist/paraglide/messages/_index.d.ts +36 -3
  53. package/dist/paraglide/messages/_index.js +71 -3
  54. package/dist/paraglide/messages/en.d.ts +5 -0
  55. package/dist/paraglide/messages/en.js +14 -0
  56. package/dist/paraglide/messages/pl.d.ts +5 -0
  57. package/dist/paraglide/messages/pl.js +14 -0
  58. package/dist/shop/adapters/fakturownia/client.d.ts +28 -0
  59. package/dist/shop/adapters/fakturownia/client.js +67 -0
  60. package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
  61. package/dist/shop/adapters/fakturownia/index.js +36 -0
  62. package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
  63. package/dist/shop/adapters/fakturownia/payload.js +45 -0
  64. package/dist/shop/cart/types.d.ts +1 -0
  65. package/dist/shop/client/index.d.ts +61 -0
  66. package/dist/shop/client/index.js +5 -1
  67. package/dist/shop/expiry.d.ts +35 -0
  68. package/dist/shop/expiry.js +68 -0
  69. package/dist/shop/http/balance-handler.d.ts +20 -0
  70. package/dist/shop/http/balance-handler.js +91 -0
  71. package/dist/shop/http/cart-handler.js +19 -0
  72. package/dist/shop/http/checkout-handler.js +30 -1
  73. package/dist/shop/http/index.d.ts +2 -0
  74. package/dist/shop/http/index.js +2 -0
  75. package/dist/shop/http/upcoming-handler.d.ts +16 -0
  76. package/dist/shop/http/upcoming-handler.js +65 -0
  77. package/dist/shop/http/webhook-handler.js +46 -9
  78. package/dist/shop/index.d.ts +7 -1
  79. package/dist/shop/index.js +10 -1
  80. package/dist/shop/nip.d.ts +12 -0
  81. package/dist/shop/nip.js +23 -0
  82. package/dist/shop/server/balance-payment.d.ts +40 -0
  83. package/dist/shop/server/balance-payment.js +140 -0
  84. package/dist/shop/server/cart-hydrate.js +2 -0
  85. package/dist/shop/server/init.d.ts +14 -0
  86. package/dist/shop/server/init.js +35 -0
  87. package/dist/shop/server/invoices.d.ts +64 -0
  88. package/dist/shop/server/invoices.js +237 -0
  89. package/dist/shop/server/orders.d.ts +38 -0
  90. package/dist/shop/server/orders.js +152 -2
  91. package/dist/shop/server/payment-policy.d.ts +35 -0
  92. package/dist/shop/server/payment-policy.js +55 -0
  93. package/dist/shop/server/payments.d.ts +29 -0
  94. package/dist/shop/server/payments.js +64 -0
  95. package/dist/shop/server/populate.d.ts +1 -1
  96. package/dist/shop/server/refund.d.ts +17 -12
  97. package/dist/shop/server/refund.js +96 -13
  98. package/dist/shop/server/shop-data.d.ts +4 -1
  99. package/dist/shop/server/shop-data.js +24 -2
  100. package/dist/shop/template.d.ts +13 -0
  101. package/dist/shop/template.js +98 -0
  102. package/dist/shop/types.d.ts +208 -1
  103. package/dist/shop/variant-attributes.d.ts +28 -0
  104. package/dist/shop/variant-attributes.js +69 -0
  105. package/dist/sveltekit/server/index.d.ts +1 -0
  106. package/dist/sveltekit/server/index.js +2 -0
  107. package/dist/types/cms.d.ts +4 -3
  108. package/dist/types/cms.schema.d.ts +1 -1
  109. package/dist/types/cms.schema.js +9 -0
  110. package/dist/types/fields.d.ts +21 -2
  111. package/dist/types/index.d.ts +1 -1
  112. package/dist/types/index.js +1 -1
  113. package/dist/types/plugins.d.ts +40 -0
  114. package/dist/types/plugins.js +4 -1
  115. package/dist/updates/0.26.1/index.d.ts +2 -0
  116. package/dist/updates/0.26.1/index.js +19 -0
  117. package/dist/updates/0.27.0/index.d.ts +2 -0
  118. package/dist/updates/0.27.0/index.js +50 -0
  119. package/dist/updates/0.28.0/index.d.ts +2 -0
  120. package/dist/updates/0.28.0/index.js +38 -0
  121. package/dist/updates/index.js +7 -1
  122. package/package.json +1 -1
  123. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  124. package/dist/paraglide/messages/hello_world.js +0 -33
  125. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  126. package/dist/paraglide/messages/login_hello.js +0 -34
  127. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  128. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -0,0 +1,91 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import { createBalanceSession, requireBalanceTokenSecret, verifyBalanceToken } from '../server/balance-payment.js';
4
+ import { getOrderByNumber } from '../server/orders.js';
5
+ function shopEnabled() {
6
+ try {
7
+ return getCMS().shopConfig !== null;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * @experimental
15
+ * HTTP handlers for the balance-payment flow. Mount at
16
+ * `/api/shop/orders/[number]/balance` (or any URL containing `number` +
17
+ * `?token=...`).
18
+ *
19
+ * - `GET` returns the minimal public order view (amount due, currency)
20
+ * when the token + balanceOwed check passes.
21
+ * - `POST` initiates a payment session for the outstanding balance using
22
+ * the order's original payment adapter and returns `{ redirectUrl,
23
+ * status }`.
24
+ *
25
+ * Both verbs return 403 when the token is invalid OR `balanceOwed` is
26
+ * false (no oracle distinction — we don't tell the caller which gate failed).
27
+ */
28
+ export function createBalanceHandler() {
29
+ return {
30
+ GET: async ({ params, url }) => {
31
+ if (!shopEnabled())
32
+ return json({ error: 'Shop not enabled' }, { status: 404 });
33
+ const orderNumber = params.number;
34
+ if (!orderNumber)
35
+ return json({ error: 'Order number required' }, { status: 400 });
36
+ const token = url.searchParams.get('token') ?? '';
37
+ const order = await getOrderByNumber(orderNumber);
38
+ if (!order)
39
+ return json({ error: 'Order not found' }, { status: 404 });
40
+ if (!order.balanceOwed || !order.partialPayment) {
41
+ return json({ error: 'No outstanding balance' }, { status: 403 });
42
+ }
43
+ const secret = requireBalanceTokenSecret();
44
+ if (!verifyBalanceToken(token, order.id, secret)) {
45
+ return json({ error: 'Invalid token' }, { status: 403 });
46
+ }
47
+ const pp = order.partialPayment;
48
+ return json({
49
+ orderNumber: order.number,
50
+ currency: order.currency,
51
+ totalGross: order.totalGross,
52
+ amountToPay: pp.balanceAmount,
53
+ paidAmount: pp.paidAmount,
54
+ paymentMethod: order.paymentMethod,
55
+ language: order.language
56
+ });
57
+ },
58
+ POST: async ({ params, url, request, getClientAddress }) => {
59
+ if (!shopEnabled())
60
+ return json({ error: 'Shop not enabled' }, { status: 404 });
61
+ const orderNumber = params.number;
62
+ if (!orderNumber)
63
+ return json({ error: 'Order number required' }, { status: 400 });
64
+ const token = url.searchParams.get('token') ?? '';
65
+ let customerIp;
66
+ try {
67
+ customerIp = getClientAddress();
68
+ }
69
+ catch {
70
+ customerIp = undefined;
71
+ }
72
+ const body = (await request.json().catch(() => ({})));
73
+ const language = typeof body.language === 'string' ? body.language : undefined;
74
+ try {
75
+ const result = await createBalanceSession(orderNumber, token, { customerIp, language });
76
+ return json({
77
+ status: result.status,
78
+ redirectUrl: result.redirectUrl ?? null,
79
+ requiresPaymentRedirect: result.status === 'redirect'
80
+ });
81
+ }
82
+ catch (err) {
83
+ const message = err instanceof Error ? err.message : 'Balance payment failed';
84
+ const code = message === 'Invalid balance token' || message === 'Order has no outstanding balance'
85
+ ? 403
86
+ : 400;
87
+ return json({ error: message }, { status: code });
88
+ }
89
+ }
90
+ };
91
+ }
@@ -1,10 +1,14 @@
1
+ import { eq } from 'drizzle-orm';
1
2
  import { json } from '@sveltejs/kit';
2
3
  import { getCMS } from '../../core/cms.js';
4
+ import { shopProductVariantsTable } from '../../db-postgres/schema/shop/index.js';
3
5
  import { addItem, readCartCookie, removeItem, setItemQty, writeCartCookie } from '../cart/cookie.js';
4
6
  import { clearCouponCookie, isValidCouponCode, normalizeCouponCode, readCouponCookie, writeCouponCookie } from '../cart/coupon-cookie.js';
5
7
  import { hydrateCart } from '../server/cart-hydrate.js';
6
8
  import { CouponError, validateCoupon } from '../server/coupons.js';
9
+ import { getShopDb } from '../server/db.js';
7
10
  import { checkRateLimit, clientKey } from '../rate-limit.js';
11
+ import { isVariantExpired } from '../expiry.js';
8
12
  function shopEnabled() {
9
13
  try {
10
14
  return getCMS().shopConfig !== null;
@@ -54,6 +58,21 @@ export function createCartHandler() {
54
58
  if (!Number.isFinite(qtyNum) || qtyNum <= 0) {
55
59
  return json({ error: 'qty must be > 0' }, { status: 400 });
56
60
  }
61
+ const expiryConfig = getCMS().shopConfig?.variantExpiry ?? null;
62
+ if (expiryConfig) {
63
+ const db = getShopDb();
64
+ const [row] = await db
65
+ .select({
66
+ id: shopProductVariantsTable.id,
67
+ attributes: shopProductVariantsTable.attributes
68
+ })
69
+ .from(shopProductVariantsTable)
70
+ .where(eq(shopProductVariantsTable.id, variantId));
71
+ if (row &&
72
+ isVariantExpired({ attributes: row.attributes }, expiryConfig)) {
73
+ return json({ error: 'VARIANT_EXPIRED', variantId }, { status: 400 });
74
+ }
75
+ }
57
76
  const items = readCartCookie(cookies);
58
77
  const next = addItem(items, variantId, qtyNum);
59
78
  writeCartCookie(cookies, next);
@@ -4,9 +4,11 @@ import { readCartCookie, writeCartCookie } from '../cart/cookie.js';
4
4
  import { clearCouponCookie, readCouponCookie } from '../cart/coupon-cookie.js';
5
5
  import { writeOrderTokenCookie } from '../cart/order-token-cookie.js';
6
6
  import { createOrderFromCart, setPaymentProviderRef } from '../server/orders.js';
7
+ import { insertPaymentRow } from '../server/payments.js';
7
8
  import { getShippingMethod } from '../server/shipping.js';
8
9
  import { checkRateLimit, clientKey } from '../rate-limit.js';
9
10
  import { requireShopConfig } from '../server/db.js';
11
+ import { isValidNip } from '../nip.js';
10
12
  function shopEnabled() {
11
13
  try {
12
14
  return getCMS().shopConfig !== null;
@@ -65,6 +67,12 @@ export function createCheckoutHandler() {
65
67
  return json({ error: 'shippingMethodId required' }, { status: 400 });
66
68
  if (!paymentMethod)
67
69
  return json({ error: 'paymentMethod required' }, { status: 400 });
70
+ // Validate the NIP up front — Fakturownia rejects an invalid one and the
71
+ // invoice later goes to KSeF, so we never persist a malformed tax id.
72
+ const customerNip = asString(body.customerNip, 20);
73
+ if (customerNip && !isValidNip(customerNip)) {
74
+ return json({ error: 'Podany NIP jest nieprawidłowy.' }, { status: 400 });
75
+ }
68
76
  const cartItems = readCartCookie(cookies);
69
77
  if (cartItems.length === 0) {
70
78
  return json({ error: 'Cart is empty' }, { status: 400 });
@@ -97,7 +105,11 @@ export function createCheckoutHandler() {
97
105
  customerEmail,
98
106
  customerName: asString(body.customerName, 200),
99
107
  customerPhone: asString(body.customerPhone, 40),
108
+ customerNip,
109
+ customerCompanyName: asString(body.customerCompanyName, 200),
100
110
  shippingAddress: asStringRecord(body.shippingAddress),
111
+ billingAddress: asStringRecord(body.billingAddress),
112
+ invoiceRequested: body.invoiceRequested === true,
101
113
  shippingMethodId,
102
114
  carrierRef,
103
115
  paymentMethod,
@@ -119,10 +131,16 @@ export function createCheckoutHandler() {
119
131
  const adapter = shop.payment.find((a) => a.id === paymentMethod);
120
132
  let paymentResult = null;
121
133
  if (adapter) {
134
+ // Under deposit policy, charge only the resolved deposit (line
135
+ // deposits + shipping) at checkout. The balance flow charges the
136
+ // remainder via a signed token later. We override totalGross on
137
+ // the OrderRef passed to the adapter so adapters that compute the
138
+ // charge from `orderRef.totalGross` Just Work — `order.totalGross`
139
+ // in the DB stays the source of truth for the full obligation.
122
140
  const orderRef = {
123
141
  id: result.order.id,
124
142
  number: result.order.number,
125
- totalGross: result.order.totalGross,
143
+ totalGross: result.amountToPay,
126
144
  currency: shop.currency,
127
145
  customerEmail: result.order.customerEmail
128
146
  };
@@ -141,6 +159,17 @@ export function createCheckoutHandler() {
141
159
  if (paymentResult.providerRef) {
142
160
  await setPaymentProviderRef(result.order.id, paymentResult.providerRef);
143
161
  }
162
+ // Track this payment as its own row so the webhook can branch
163
+ // by `kind` (full / deposit / balance) and `refundOrder` can
164
+ // target deposit-vs-balance independently.
165
+ await insertPaymentRow({
166
+ orderId: result.order.id,
167
+ provider: adapter.id,
168
+ providerRef: paymentResult.providerRef ?? null,
169
+ kind: result.paymentKind,
170
+ amount: result.amountToPay,
171
+ currency: shop.currency
172
+ });
144
173
  }
145
174
  catch (err) {
146
175
  console.error(`[shop] createPayment failed for ${result.order.number}:`, err);
@@ -8,3 +8,5 @@ export { createRetryPaymentHandler } from './retry-payment-handler.js';
8
8
  export { createCarrierConfigHandler } from './carrier-handler.js';
9
9
  export { createCarrierWebhookHandler } from './carrier-webhook-handler.js';
10
10
  export { createShipmentLabelHandler } from './shipment-label-handler.js';
11
+ export { createUpcomingVariantsHandler } from './upcoming-handler.js';
12
+ export { createBalanceHandler } from './balance-handler.js';
@@ -8,3 +8,5 @@ export { createRetryPaymentHandler } from './retry-payment-handler.js';
8
8
  export { createCarrierConfigHandler } from './carrier-handler.js';
9
9
  export { createCarrierWebhookHandler } from './carrier-webhook-handler.js';
10
10
  export { createShipmentLabelHandler } from './shipment-label-handler.js';
11
+ export { createUpcomingVariantsHandler } from './upcoming-handler.js';
12
+ export { createBalanceHandler } from './balance-handler.js';
@@ -0,0 +1,16 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ /**
3
+ * Build a SvelteKit `GET` handler for upcoming variants of a single product.
4
+ * Mount at `/api/shop/products/[id]/variants/upcoming`.
5
+ *
6
+ * Returns `{ items: VariantRow[] }`. When `defineShop({ variantExpiry })` is
7
+ * configured, expired variants are filtered out; otherwise every variant for
8
+ * the product is returned. Order: `attributes.<source>` ascending if expiry
9
+ * config is set, otherwise insertion order.
10
+ *
11
+ * @experimental — HTTP shape is stable for envet pilot but may grow in 1.x
12
+ * (e.g. pagination, language filter) once a second consumer lands.
13
+ */
14
+ export declare function createUpcomingVariantsHandler(): {
15
+ GET: RequestHandler;
16
+ };
@@ -0,0 +1,65 @@
1
+ import { asc, eq } from 'drizzle-orm';
2
+ import { json } from '@sveltejs/kit';
3
+ import { getCMS } from '../../core/cms.js';
4
+ import { shopProductVariantsTable } from '../../db-postgres/schema/shop/index.js';
5
+ import { filterUpcoming } from '../expiry.js';
6
+ import { getShopDb } from '../server/db.js';
7
+ function shopEnabled() {
8
+ try {
9
+ return getCMS().shopConfig !== null;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ /**
16
+ * Build a SvelteKit `GET` handler for upcoming variants of a single product.
17
+ * Mount at `/api/shop/products/[id]/variants/upcoming`.
18
+ *
19
+ * Returns `{ items: VariantRow[] }`. When `defineShop({ variantExpiry })` is
20
+ * configured, expired variants are filtered out; otherwise every variant for
21
+ * the product is returned. Order: `attributes.<source>` ascending if expiry
22
+ * config is set, otherwise insertion order.
23
+ *
24
+ * @experimental — HTTP shape is stable for envet pilot but may grow in 1.x
25
+ * (e.g. pagination, language filter) once a second consumer lands.
26
+ */
27
+ export function createUpcomingVariantsHandler() {
28
+ return {
29
+ GET: async ({ params }) => {
30
+ if (!shopEnabled())
31
+ return json({ error: 'Shop not enabled' }, { status: 404 });
32
+ const productId = params.id;
33
+ if (!productId)
34
+ return json({ error: 'Product id required' }, { status: 400 });
35
+ const db = getShopDb();
36
+ const rows = await db
37
+ .select()
38
+ .from(shopProductVariantsTable)
39
+ .where(eq(shopProductVariantsTable.productId, productId))
40
+ .orderBy(asc(shopProductVariantsTable.createdAt));
41
+ const expiryConfig = getCMS().shopConfig?.variantExpiry ?? null;
42
+ const variants = rows.map((r) => ({
43
+ ...r,
44
+ attributes: r.attributes
45
+ }));
46
+ const upcoming = filterUpcoming(variants, expiryConfig);
47
+ if (expiryConfig) {
48
+ upcoming.sort((a, b) => {
49
+ const aVal = a.attributes?.[expiryConfig.source];
50
+ const bVal = b.attributes?.[expiryConfig.source];
51
+ const aTs = typeof aVal === 'string' ? Date.parse(aVal) : NaN;
52
+ const bTs = typeof bVal === 'string' ? Date.parse(bVal) : NaN;
53
+ if (Number.isNaN(aTs) && Number.isNaN(bTs))
54
+ return 0;
55
+ if (Number.isNaN(aTs))
56
+ return 1;
57
+ if (Number.isNaN(bTs))
58
+ return -1;
59
+ return aTs - bTs;
60
+ });
61
+ }
62
+ return json({ items: upcoming });
63
+ }
64
+ };
65
+ }
@@ -1,7 +1,8 @@
1
1
  import { json } from '@sveltejs/kit';
2
2
  import { getCMS } from '../../core/cms.js';
3
3
  import { requireShopConfig } from '../server/db.js';
4
- import { getOrderByNumber, updateOrderStatus } from '../server/orders.js';
4
+ import { getOrderByNumber, markBalancePaid, updateOrderStatus } from '../server/orders.js';
5
+ import { findPaymentByProviderRef, markPaymentFailed, markPaymentPaid } from '../server/payments.js';
5
6
  import { checkRateLimit, clientKey } from '../rate-limit.js';
6
7
  import { isTerminalStatus, mapEventToStatus } from './webhook-logic.js';
7
8
  import { markWebhookEventProcessed, reserveWebhookEvent } from './webhook-idempotency.js';
@@ -59,24 +60,60 @@ export function createPaymentWebhookHandler() {
59
60
  await markWebhookEventProcessed(reservation.rowId, null);
60
61
  return json({ received: true });
61
62
  }
63
+ // Match payment row so we know whether the event targets a `full`,
64
+ // `deposit`, or `balance` payment row. Falls back to `'full'` when
65
+ // no row exists (back-compat with orders created before 0.27).
66
+ const paymentRow = await findPaymentByProviderRef(provider, event.providerRef);
67
+ const kind = paymentRow?.kind ?? 'full';
68
+ const targetStatus = mapEventToStatus(event);
69
+ if (!targetStatus) {
70
+ if (reservation.rowId)
71
+ await markWebhookEventProcessed(reservation.rowId, order.id);
72
+ return json({ received: true, noop: true });
73
+ }
74
+ // Balance events must NOT re-trigger updateOrderStatus(paid) — the
75
+ // order already transitioned when the deposit cleared. Use the
76
+ // markBalancePaid path which only updates partialPayment/balanceOwed.
77
+ if (kind === 'balance') {
78
+ try {
79
+ if (targetStatus === 'paid') {
80
+ await markBalancePaid(order.id);
81
+ if (paymentRow)
82
+ await markPaymentPaid(paymentRow.id);
83
+ }
84
+ else if (targetStatus === 'paymentRejected' && paymentRow) {
85
+ await markPaymentFailed(paymentRow.id);
86
+ }
87
+ }
88
+ catch (err) {
89
+ console.error(`[shop] balance webhook failed for ${order.number}:`, err);
90
+ return json({ error: 'Processing failed' }, { status: 500 });
91
+ }
92
+ if (reservation.rowId)
93
+ await markWebhookEventProcessed(reservation.rowId, order.id);
94
+ return json({ received: true, kind: 'balance' });
95
+ }
62
96
  // Secondary idempotency — terminal states are no-ops (covers adapters
63
97
  // that didn't surface eventId).
64
98
  if (isTerminalStatus(order.status)) {
99
+ if (paymentRow && targetStatus === 'paid')
100
+ await markPaymentPaid(paymentRow.id);
65
101
  if (reservation.rowId)
66
102
  await markWebhookEventProcessed(reservation.rowId, order.id);
67
103
  return json({ received: true, idempotent: true });
68
104
  }
69
- const targetStatus = mapEventToStatus(event);
70
- if (!targetStatus) {
71
- if (reservation.rowId)
72
- await markWebhookEventProcessed(reservation.rowId, order.id);
73
- return json({ received: true, noop: true });
74
- }
75
105
  try {
76
106
  await updateOrderStatus(order.id, targetStatus, {
77
107
  note: `Payment webhook (${provider})`,
78
- changedBy: 'payment-webhook'
108
+ changedBy: 'payment-webhook',
109
+ paymentKind: kind
79
110
  });
111
+ if (paymentRow) {
112
+ if (targetStatus === 'paid')
113
+ await markPaymentPaid(paymentRow.id);
114
+ else if (targetStatus === 'paymentRejected')
115
+ await markPaymentFailed(paymentRow.id);
116
+ }
80
117
  }
81
118
  catch (err) {
82
119
  console.error(`[shop] updateOrderStatus failed for ${order.number}:`, err);
@@ -85,7 +122,7 @@ export function createPaymentWebhookHandler() {
85
122
  }
86
123
  if (reservation.rowId)
87
124
  await markWebhookEventProcessed(reservation.rowId, order.id);
88
- return json({ received: true });
125
+ return json({ received: true, kind });
89
126
  }
90
127
  };
91
128
  }
@@ -1,5 +1,7 @@
1
1
  import type { ShopConfig, ResolvedShopConfig } from './types.js';
2
2
  export declare function defineShop(config: ShopConfig): ResolvedShopConfig;
3
+ export { InvalidVariantAttributesError } from './variant-attributes.js';
4
+ export { isVariantExpired, filterUpcoming, VariantExpiredError } from './expiry.js';
3
5
  export { manualAdapter } from './adapters/manual/index.js';
4
6
  export { payuAdapter } from './adapters/payu/index.js';
5
7
  export type { PayuAdapterOptions } from './adapters/payu/index.js';
@@ -7,4 +9,8 @@ export { stripeAdapter } from './adapters/stripe/index.js';
7
9
  export type { StripeAdapterOptions } from './adapters/stripe/index.js';
8
10
  export { inpostAdapter } from './adapters/inpost/index.js';
9
11
  export type { InpostAdapterOptions, InpostSenderAddress, GeowidgetConfigPreset, InpostEnvironment } from './adapters/inpost/index.js';
10
- export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText } from './types.js';
12
+ export { fakturowniaAdapter } from './adapters/fakturownia/index.js';
13
+ export type { FakturowniaAdapterOptions } from './adapters/fakturownia/index.js';
14
+ export { isValidNip } from './nip.js';
15
+ export type { ShopConfig, ResolvedShopConfig, Currency, OrderStatus, PaymentAdapter, PaymentCreateContext, PaymentRefundInput, PaymentRefundResult, CarrierAdapter, CarrierEvent, ShipmentCreateInput, ShipmentCreateResult, ShipmentLabel, ConsentConfig, ShopFeatures, PaymentCreateResult, PaymentEvent, OrderRef, CouponRef, I18nText, VariantAttribute, VariantAttributeText, VariantAttributeNumber, VariantAttributeDatetime, VariantAttributeSelect, VariantAttributeBoolean, VariantAttributeImage, VariantAttributeEntry, VariantAttributeSlug, VariantLabelConfig, VariantExpiryConfig, PaymentPolicy, DepositAmount, PartialPayment, InvoicingAdapter, InvoiceIssuePolicy, InvoiceBuyer, InvoiceLineItem, InvoicePayload, InvoiceCreateResult, InvoiceContext } from './types.js';
16
+ export { interpolateTemplate } from './template.js';
@@ -12,11 +12,20 @@ export function defineShop(config) {
12
12
  webhook: config.rateLimit?.webhook ?? { limit: 60, windowSec: 60 }
13
13
  },
14
14
  carriers: config.carriers ?? [],
15
+ invoicing: config.invoicing ?? null,
15
16
  consents: config.consents ?? [],
16
- orderViewUrl: config.orderViewUrl ?? '/shop/order/{orderNumber}?token={accessToken}'
17
+ orderViewUrl: config.orderViewUrl ?? '/shop/order/{orderNumber}?token={accessToken}',
18
+ variantAttributes: config.variantAttributes ?? {},
19
+ variantLabel: config.variantLabel ?? null,
20
+ variantExpiry: config.variantExpiry ?? null
17
21
  };
18
22
  }
23
+ export { InvalidVariantAttributesError } from './variant-attributes.js';
24
+ export { isVariantExpired, filterUpcoming, VariantExpiredError } from './expiry.js';
19
25
  export { manualAdapter } from './adapters/manual/index.js';
20
26
  export { payuAdapter } from './adapters/payu/index.js';
21
27
  export { stripeAdapter } from './adapters/stripe/index.js';
22
28
  export { inpostAdapter } from './adapters/inpost/index.js';
29
+ export { fakturowniaAdapter } from './adapters/fakturownia/index.js';
30
+ export { isValidNip } from './nip.js';
31
+ export { interpolateTemplate } from './template.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validate a Polish NIP (tax id) — 10 digits with a weighted checksum.
3
+ *
4
+ * Accepts dashes, spaces and an optional `PL` prefix; these are stripped before
5
+ * validation. Returns `false` for any malformed input rather than throwing.
6
+ *
7
+ * Validation is intentionally strict: an invoice issued with an invalid NIP is
8
+ * rejected by Fakturownia and would later fail KSeF submission, so the buyer's
9
+ * NIP is verified at checkout time before it ever reaches the adapter.
10
+ * @public
11
+ */
12
+ export declare function isValidNip(nip: string): boolean;
@@ -0,0 +1,23 @@
1
+ const NIP_WEIGHTS = [6, 5, 7, 2, 3, 4, 5, 6, 7];
2
+ /**
3
+ * Validate a Polish NIP (tax id) — 10 digits with a weighted checksum.
4
+ *
5
+ * Accepts dashes, spaces and an optional `PL` prefix; these are stripped before
6
+ * validation. Returns `false` for any malformed input rather than throwing.
7
+ *
8
+ * Validation is intentionally strict: an invoice issued with an invalid NIP is
9
+ * rejected by Fakturownia and would later fail KSeF submission, so the buyer's
10
+ * NIP is verified at checkout time before it ever reaches the adapter.
11
+ * @public
12
+ */
13
+ export function isValidNip(nip) {
14
+ const normalized = nip.replace(/^PL/i, '').replace(/[\s-]/g, '');
15
+ if (!/^\d{10}$/.test(normalized))
16
+ return false;
17
+ const digits = normalized.split('').map(Number);
18
+ const sum = NIP_WEIGHTS.reduce((acc, weight, i) => acc + weight * digits[i], 0);
19
+ const checksum = sum % 11;
20
+ if (checksum === 10)
21
+ return false;
22
+ return checksum === digits[9];
23
+ }
@@ -0,0 +1,40 @@
1
+ import type { PaymentCreateContext, PaymentCreateResult } from '../types.js';
2
+ /**
3
+ * @internal
4
+ * Generate a signed balance-payment token for `orderId`. Format:
5
+ * `base64url(payload).base64url(hmac-sha256(payload, secret))`. The token
6
+ * is deterministic for a given (orderId, secret) pair — server-side
7
+ * invalidation is by `order.balanceOwed` rather than rotation.
8
+ */
9
+ export declare function generateBalanceToken(orderId: string, secret: string): string;
10
+ /**
11
+ * @internal
12
+ * Verify a balance-payment token. Checks payload structure, `type` claim,
13
+ * orderId match, and constant-time HMAC compare. Does NOT consult the
14
+ * database — call `order.balanceOwed === true` separately as the
15
+ * server-side invalidation gate.
16
+ */
17
+ export declare function verifyBalanceToken(token: string, orderId: string, secret: string): boolean;
18
+ /**
19
+ * @internal
20
+ * Resolve the HMAC secret for balance tokens from the environment. Throws
21
+ * when missing — admin must set `INCLUDIO_BALANCE_TOKEN_SECRET` before
22
+ * enabling any deposit `paymentPolicy`. We refuse to silently downgrade
23
+ * because tokens without entropy would let arbitrary customers pay a
24
+ * balance for arbitrary orders.
25
+ */
26
+ export declare function requireBalanceTokenSecret(): string;
27
+ export interface CreateBalanceSessionResult {
28
+ status: PaymentCreateResult['status'];
29
+ redirectUrl?: string;
30
+ }
31
+ /**
32
+ * @experimental
33
+ * Initiate a payment session for the outstanding balance on a deposit order.
34
+ * Verifies the token + `order.balanceOwed === true` + reuses the order's
35
+ * original payment adapter. Inserts a `shop_payments` row tagged
36
+ * `kind='balance'` so the webhook can route the eventual paid event to
37
+ * `markBalancePaid`. Errors when token invalid, order missing, balance
38
+ * already cleared, or adapter not configured.
39
+ */
40
+ export declare function createBalanceSession(orderNumber: string, token: string, ctx?: PaymentCreateContext): Promise<CreateBalanceSessionResult>;
@@ -0,0 +1,140 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { eq } from 'drizzle-orm';
3
+ import { shopOrdersTable } from '../../db-postgres/schema/shop/index.js';
4
+ import { getShopDb, requireShopConfig } from './db.js';
5
+ import { getOrderByNumber } from './orders.js';
6
+ import { insertPaymentRow } from './payments.js';
7
+ function base64url(buf) {
8
+ return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
9
+ }
10
+ function base64urlDecode(s) {
11
+ if (!s)
12
+ return null;
13
+ const padded = s.replace(/-/g, '+').replace(/_/g, '/');
14
+ const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
15
+ try {
16
+ return Buffer.from(padded + pad, 'base64');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function sign(payload, secret) {
23
+ return createHmac('sha256', secret).update(payload).digest();
24
+ }
25
+ /**
26
+ * @internal
27
+ * Generate a signed balance-payment token for `orderId`. Format:
28
+ * `base64url(payload).base64url(hmac-sha256(payload, secret))`. The token
29
+ * is deterministic for a given (orderId, secret) pair — server-side
30
+ * invalidation is by `order.balanceOwed` rather than rotation.
31
+ */
32
+ export function generateBalanceToken(orderId, secret) {
33
+ const payload = { orderId, type: 'balance' };
34
+ const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf8');
35
+ const sig = sign(payloadBuf, secret);
36
+ return `${base64url(payloadBuf)}.${base64url(sig)}`;
37
+ }
38
+ /**
39
+ * @internal
40
+ * Verify a balance-payment token. Checks payload structure, `type` claim,
41
+ * orderId match, and constant-time HMAC compare. Does NOT consult the
42
+ * database — call `order.balanceOwed === true` separately as the
43
+ * server-side invalidation gate.
44
+ */
45
+ export function verifyBalanceToken(token, orderId, secret) {
46
+ if (!token || typeof token !== 'string')
47
+ return false;
48
+ const dotIndex = token.indexOf('.');
49
+ if (dotIndex < 1 || dotIndex >= token.length - 1)
50
+ return false;
51
+ const payloadPart = token.slice(0, dotIndex);
52
+ const sigPart = token.slice(dotIndex + 1);
53
+ const payloadBuf = base64urlDecode(payloadPart);
54
+ const sigBuf = base64urlDecode(sigPart);
55
+ if (!payloadBuf || !sigBuf)
56
+ return false;
57
+ let payload;
58
+ try {
59
+ payload = JSON.parse(payloadBuf.toString('utf8'));
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ if (!payload || payload.type !== 'balance' || payload.orderId !== orderId)
65
+ return false;
66
+ const expected = sign(payloadBuf, secret);
67
+ if (expected.length !== sigBuf.length)
68
+ return false;
69
+ return timingSafeEqual(new Uint8Array(expected), new Uint8Array(sigBuf));
70
+ }
71
+ /**
72
+ * @internal
73
+ * Resolve the HMAC secret for balance tokens from the environment. Throws
74
+ * when missing — admin must set `INCLUDIO_BALANCE_TOKEN_SECRET` before
75
+ * enabling any deposit `paymentPolicy`. We refuse to silently downgrade
76
+ * because tokens without entropy would let arbitrary customers pay a
77
+ * balance for arbitrary orders.
78
+ */
79
+ export function requireBalanceTokenSecret() {
80
+ const secret = process.env.INCLUDIO_BALANCE_TOKEN_SECRET;
81
+ if (!secret || secret.length < 16) {
82
+ throw new Error('INCLUDIO_BALANCE_TOKEN_SECRET env var required (≥16 chars) for balance link generation.');
83
+ }
84
+ return secret;
85
+ }
86
+ /**
87
+ * @experimental
88
+ * Initiate a payment session for the outstanding balance on a deposit order.
89
+ * Verifies the token + `order.balanceOwed === true` + reuses the order's
90
+ * original payment adapter. Inserts a `shop_payments` row tagged
91
+ * `kind='balance'` so the webhook can route the eventual paid event to
92
+ * `markBalancePaid`. Errors when token invalid, order missing, balance
93
+ * already cleared, or adapter not configured.
94
+ */
95
+ export async function createBalanceSession(orderNumber, token, ctx) {
96
+ const shop = requireShopConfig();
97
+ const order = await getOrderByNumber(orderNumber);
98
+ if (!order)
99
+ throw new Error('Order not found');
100
+ if (!order.balanceOwed || !order.partialPayment) {
101
+ throw new Error('Order has no outstanding balance');
102
+ }
103
+ if (!order.paymentMethod)
104
+ throw new Error('Order has no payment method');
105
+ const secret = requireBalanceTokenSecret();
106
+ if (!verifyBalanceToken(token, order.id, secret)) {
107
+ throw new Error('Invalid balance token');
108
+ }
109
+ const adapter = shop.payment.find((a) => a.id === order.paymentMethod);
110
+ if (!adapter)
111
+ throw new Error(`Payment provider "${order.paymentMethod}" is not configured`);
112
+ const balanceAmount = order.partialPayment.balanceAmount;
113
+ const orderRef = {
114
+ id: order.id,
115
+ number: order.number,
116
+ totalGross: balanceAmount,
117
+ currency: shop.currency,
118
+ customerEmail: order.customerEmail
119
+ };
120
+ const result = await adapter.createPayment(orderRef, ctx);
121
+ await insertPaymentRow({
122
+ orderId: order.id,
123
+ provider: adapter.id,
124
+ providerRef: result.providerRef ?? null,
125
+ kind: 'balance',
126
+ amount: balanceAmount,
127
+ currency: shop.currency
128
+ });
129
+ // Mirror the latest providerRef on the order so refunds / refresh-payment
130
+ // (which look at order.paymentProviderRef as a fallback) point to the
131
+ // balance row first. Per-kind refund still uses shop_payments.kind.
132
+ if (result.providerRef) {
133
+ const db = getShopDb();
134
+ await db
135
+ .update(shopOrdersTable)
136
+ .set({ paymentProviderRef: result.providerRef, updatedAt: new Date() })
137
+ .where(eq(shopOrdersTable.id, order.id));
138
+ }
139
+ return { status: result.status, redirectUrl: result.redirectUrl };
140
+ }
@@ -127,6 +127,7 @@ export async function hydrateCart(items, opts = {}) {
127
127
  variantId: ref.variantId,
128
128
  qty: effectiveQty,
129
129
  entryId: product.entryId,
130
+ productId: product.id,
130
131
  variantName: variant.name ?? null,
131
132
  variantSku: variant.sku,
132
133
  productTitle: title,
@@ -205,6 +206,7 @@ export async function hydrateCart(items, opts = {}) {
205
206
  variantId: ref.variantId,
206
207
  qty: 0,
207
208
  entryId: '',
209
+ productId: '',
208
210
  variantName: null,
209
211
  variantSku: null,
210
212
  productTitle: null,