includio-cms 0.26.0 → 0.27.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 (112) hide show
  1. package/API.md +42 -2
  2. package/CHANGELOG.md +65 -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 +107 -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 +58 -24
  29. package/dist/admin/remote/shop.remote.js +61 -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/order.d.ts +37 -1
  41. package/dist/db-postgres/schema/shop/order.js +3 -1
  42. package/dist/db-postgres/schema/shop/payment.d.ts +20 -0
  43. package/dist/db-postgres/schema/shop/payment.js +4 -1
  44. package/dist/db-postgres/schema/shop/product.d.ts +20 -0
  45. package/dist/db-postgres/schema/shop/product.js +3 -1
  46. package/dist/db-postgres/schema/shop/productVariant.d.ts +12 -2
  47. package/dist/db-postgres/schema/shop/productVariant.js +22 -0
  48. package/dist/paraglide/messages/_index.d.ts +36 -3
  49. package/dist/paraglide/messages/_index.js +71 -3
  50. package/dist/paraglide/messages/en.d.ts +5 -0
  51. package/dist/paraglide/messages/en.js +14 -0
  52. package/dist/paraglide/messages/pl.d.ts +5 -0
  53. package/dist/paraglide/messages/pl.js +14 -0
  54. package/dist/shop/cart/types.d.ts +1 -0
  55. package/dist/shop/client/index.d.ts +54 -0
  56. package/dist/shop/client/index.js +5 -1
  57. package/dist/shop/expiry.d.ts +35 -0
  58. package/dist/shop/expiry.js +68 -0
  59. package/dist/shop/http/balance-handler.d.ts +20 -0
  60. package/dist/shop/http/balance-handler.js +91 -0
  61. package/dist/shop/http/cart-handler.js +19 -0
  62. package/dist/shop/http/checkout-handler.js +19 -1
  63. package/dist/shop/http/index.d.ts +2 -0
  64. package/dist/shop/http/index.js +2 -0
  65. package/dist/shop/http/upcoming-handler.d.ts +16 -0
  66. package/dist/shop/http/upcoming-handler.js +65 -0
  67. package/dist/shop/http/webhook-handler.js +46 -9
  68. package/dist/shop/index.d.ts +4 -1
  69. package/dist/shop/index.js +7 -1
  70. package/dist/shop/server/balance-payment.d.ts +40 -0
  71. package/dist/shop/server/balance-payment.js +140 -0
  72. package/dist/shop/server/cart-hydrate.js +2 -0
  73. package/dist/shop/server/init.d.ts +14 -0
  74. package/dist/shop/server/init.js +35 -0
  75. package/dist/shop/server/orders.d.ts +34 -0
  76. package/dist/shop/server/orders.js +141 -2
  77. package/dist/shop/server/payment-policy.d.ts +35 -0
  78. package/dist/shop/server/payment-policy.js +55 -0
  79. package/dist/shop/server/payments.d.ts +29 -0
  80. package/dist/shop/server/payments.js +64 -0
  81. package/dist/shop/server/populate.d.ts +1 -1
  82. package/dist/shop/server/refund.d.ts +17 -12
  83. package/dist/shop/server/refund.js +96 -13
  84. package/dist/shop/server/shop-data.d.ts +4 -1
  85. package/dist/shop/server/shop-data.js +24 -2
  86. package/dist/shop/template.d.ts +13 -0
  87. package/dist/shop/template.js +98 -0
  88. package/dist/shop/types.d.ts +142 -1
  89. package/dist/shop/variant-attributes.d.ts +28 -0
  90. package/dist/shop/variant-attributes.js +69 -0
  91. package/dist/sveltekit/server/index.d.ts +1 -0
  92. package/dist/sveltekit/server/index.js +2 -0
  93. package/dist/types/cms.d.ts +4 -3
  94. package/dist/types/cms.schema.d.ts +1 -1
  95. package/dist/types/cms.schema.js +9 -0
  96. package/dist/types/fields.d.ts +21 -2
  97. package/dist/types/index.d.ts +1 -1
  98. package/dist/types/index.js +1 -1
  99. package/dist/types/plugins.d.ts +40 -0
  100. package/dist/types/plugins.js +4 -1
  101. package/dist/updates/0.26.1/index.d.ts +2 -0
  102. package/dist/updates/0.26.1/index.js +19 -0
  103. package/dist/updates/0.27.0/index.d.ts +2 -0
  104. package/dist/updates/0.27.0/index.js +50 -0
  105. package/dist/updates/index.js +5 -1
  106. package/package.json +1 -1
  107. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  108. package/dist/paraglide/messages/hello_world.js +0 -33
  109. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  110. package/dist/paraglide/messages/login_hello.js +0 -34
  111. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  112. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -7,7 +7,24 @@ import { resolveShippingPrice } from './shipping.js';
7
7
  import { generateOrderNumber } from './order-number.js';
8
8
  import { sendLowStockEmail, sendOrderStatusEmail } from './email.js';
9
9
  import { isPaymentMethodAllowed } from './payment-compat.js';
10
+ import { isVariantExpired, VariantExpiredError } from '../expiry.js';
11
+ import { resolvePaymentAmount } from './payment-policy.js';
10
12
  import { CouponError, recordCouponRedemption, releaseCouponSlot, reserveCouponSlot, validateCoupon } from './coupons.js';
13
+ /**
14
+ * @public
15
+ * Thrown by `createOrderFromCart` when the cart contains items from multiple
16
+ * products and at least one of them has a deposit `paymentPolicy`. Deposit
17
+ * orders must span a single product (so the balance link/refund-per-kind flow
18
+ * stays unambiguous). Mixed-product carts are accepted only when every
19
+ * involved product has a `full` (or null) policy.
20
+ */
21
+ export class MixedPaymentPolicyError extends Error {
22
+ code = 'MIXED_PAYMENT_POLICY';
23
+ constructor(message = 'Cart mixes a deposit-policy product with other products.') {
24
+ super(message);
25
+ this.name = 'MixedPaymentPolicyError';
26
+ }
27
+ }
11
28
  const STOCK_RESERVATION_TTL_MINUTES = 30;
12
29
  async function purgeExpiredReservations() {
13
30
  const db = getShopDb();
@@ -97,6 +114,43 @@ export async function createOrderFromCart(input) {
97
114
  discountAmount: snapshot.subtotalGross - snapshot.totalGross
98
115
  };
99
116
  }
117
+ // Payment policy lookup + mixed-cart guard. Load each line's product
118
+ // paymentPolicy from shop_products. If any product has a deposit policy
119
+ // and the cart spans more than one product, refuse with
120
+ // MIXED_PAYMENT_POLICY (keeps balance link / refund-per-kind unambiguous).
121
+ const uniqueProductIds = Array.from(new Set(snapshot.items.map((l) => l.productId).filter(Boolean)));
122
+ const productPolicyRows = uniqueProductIds.length > 0
123
+ ? await db
124
+ .select({
125
+ id: shopProductsTable.id,
126
+ paymentPolicy: shopProductsTable.paymentPolicy
127
+ })
128
+ .from(shopProductsTable)
129
+ .where(inArray(shopProductsTable.id, uniqueProductIds))
130
+ : [];
131
+ const policyByProductId = new Map(productPolicyRows.map((r) => [r.id, r.paymentPolicy]));
132
+ const hasDepositPolicy = productPolicyRows.some((r) => r.paymentPolicy?.type === 'deposit');
133
+ if (hasDepositPolicy && uniqueProductIds.length > 1) {
134
+ throw new MixedPaymentPolicyError();
135
+ }
136
+ // Variant expiry guard (opt-in via defineShop.variantExpiry). Run before
137
+ // stock reservation so expired variants surface a domain error instead of
138
+ // taking a reservation slot they will never use.
139
+ if (shop.variantExpiry) {
140
+ const variantIds = snapshot.items.map((l) => l.variantId);
141
+ const variantRows = await db
142
+ .select({
143
+ id: shopProductVariantsTable.id,
144
+ attributes: shopProductVariantsTable.attributes
145
+ })
146
+ .from(shopProductVariantsTable)
147
+ .where(inArray(shopProductVariantsTable.id, variantIds));
148
+ for (const v of variantRows) {
149
+ if (isVariantExpired({ attributes: v.attributes }, shop.variantExpiry)) {
150
+ throw new VariantExpiredError(v.id);
151
+ }
152
+ }
153
+ }
100
154
  // Stock availability under active reservations (when stock feature on)
101
155
  if (shop.features.stock) {
102
156
  await purgeExpiredReservations();
@@ -120,6 +174,30 @@ export async function createOrderFromCart(input) {
120
174
  const totalNet = snapshot.totalNet + shippingResolved.net;
121
175
  const totalGross = snapshot.totalGross + shippingResolved.gross;
122
176
  const totalVat = snapshot.totalVat + shippingResolved.vat;
177
+ // Resolve per-line payment amounts under each product's paymentPolicy.
178
+ // `depositSum` is what we charge at checkout under deposit policy; the
179
+ // remainder + shipping rolls into `partialPayment.balanceAmount`.
180
+ let depositSum = 0;
181
+ let goodsBalance = 0;
182
+ let anyDepositLine = false;
183
+ for (const line of snapshot.items) {
184
+ const policy = policyByProductId.get(line.productId) ?? null;
185
+ const resolved = resolvePaymentAmount(policy, line.lineGross);
186
+ depositSum += resolved.amountToPay;
187
+ goodsBalance += resolved.balanceAmount;
188
+ if (resolved.kind === 'deposit')
189
+ anyDepositLine = true;
190
+ }
191
+ const paymentKind = anyDepositLine ? 'deposit' : 'full';
192
+ const amountToPay = anyDepositLine ? depositSum + shippingResolved.gross : totalGross;
193
+ const partialPayment = anyDepositLine
194
+ ? {
195
+ kind: 'deposit',
196
+ paidAmount: 0,
197
+ balanceAmount: goodsBalance,
198
+ paidAt: null
199
+ }
200
+ : null;
123
201
  // Consents snapshot
124
202
  const consentSnapshot = shop.consents.map((c) => {
125
203
  const accepted = input.consents?.find((x) => x.id === c.id)?.accepted ?? false;
@@ -171,7 +249,9 @@ export async function createOrderFromCart(input) {
171
249
  paymentMethod: input.paymentMethod,
172
250
  consents: consentSnapshot,
173
251
  notes: input.notes ?? null,
174
- language: input.language ?? cms.languages[0] ?? null
252
+ language: input.language ?? cms.languages[0] ?? null,
253
+ partialPayment,
254
+ balanceOwed: false
175
255
  })
176
256
  .returning();
177
257
  order = inserted;
@@ -237,7 +317,9 @@ export async function createOrderFromCart(input) {
237
317
  return {
238
318
  order,
239
319
  items,
240
- requiresPaymentRedirect: false
320
+ requiresPaymentRedirect: false,
321
+ amountToPay,
322
+ paymentKind
241
323
  };
242
324
  }
243
325
  export async function updateOrderStatus(orderId, status, opts = {}) {
@@ -248,6 +330,32 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
248
330
  if (order.status === status)
249
331
  return order;
250
332
  const shop = requireShopConfig();
333
+ // Deposit-kind transition to `paid`: bump partialPayment.paidAmount to the
334
+ // deposit amount, stamp paidAt, and flag balanceOwed = true. Stock is
335
+ // permanently confirmed by the existing `paid` branch below — no TTL
336
+ // release. Balance-kind transitions skip the order-level status change
337
+ // (handled separately by markBalancePaid) so we never reach this branch.
338
+ //
339
+ // Auto-detect deposit context when caller (e.g. admin manual mark-paid)
340
+ // didn't pass paymentKind: a non-balance-owed deposit order with paidAt
341
+ // still null = the initial deposit payment is the one being confirmed.
342
+ const partial = order.partialPayment;
343
+ const effectiveKind = opts.paymentKind ??
344
+ (partial?.kind === 'deposit' && !order.balanceOwed && partial.paidAt == null
345
+ ? 'deposit'
346
+ : undefined);
347
+ if (status === 'paid' && effectiveKind === 'deposit' && order.partialPayment) {
348
+ const pp = order.partialPayment;
349
+ const paidAt = new Date().toISOString();
350
+ const paidAmount = order.totalGross - pp.balanceAmount;
351
+ await db
352
+ .update(shopOrdersTable)
353
+ .set({
354
+ partialPayment: { ...pp, paidAmount, paidAt },
355
+ balanceOwed: true
356
+ })
357
+ .where(eq(shopOrdersTable.id, orderId));
358
+ }
251
359
  await db
252
360
  .update(shopOrdersTable)
253
361
  .set({ status, updatedAt: new Date() })
@@ -324,6 +432,37 @@ export async function updateOrderStatus(orderId, status, opts = {}) {
324
432
  void sendOrderStatusEmail(orderId, status);
325
433
  return updated;
326
434
  }
435
+ /**
436
+ * @internal
437
+ * Mark the remaining balance on a deposit order as paid. Idempotent — no-op
438
+ * when `balanceOwed` is already false. Updates `partialPayment.paidAmount`
439
+ * to the full `order.totalGross` (deposit + balance), stamps `paidAt`, and
440
+ * clears `balanceOwed`. Stock decrement / order status are untouched — the
441
+ * order already transitioned to `paid` when the deposit cleared.
442
+ */
443
+ export async function markBalancePaid(orderId) {
444
+ const db = getShopDb();
445
+ const [order] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
446
+ if (!order)
447
+ return null;
448
+ if (!order.balanceOwed || !order.partialPayment)
449
+ return order;
450
+ const pp = order.partialPayment;
451
+ await db
452
+ .update(shopOrdersTable)
453
+ .set({
454
+ partialPayment: {
455
+ ...pp,
456
+ paidAmount: order.totalGross,
457
+ paidAt: new Date().toISOString()
458
+ },
459
+ balanceOwed: false,
460
+ updatedAt: new Date()
461
+ })
462
+ .where(eq(shopOrdersTable.id, orderId));
463
+ const [updated] = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId));
464
+ return updated ?? null;
465
+ }
327
466
  export async function setPaymentProviderRef(orderId, ref) {
328
467
  const db = getShopDb();
329
468
  await db
@@ -0,0 +1,35 @@
1
+ import type { DepositAmount, PaymentPolicy } from '../types.js';
2
+ /**
3
+ * @public
4
+ * Result of resolving a per-line payment amount under a `PaymentPolicy`.
5
+ * `amountToPay` is the minor-unit amount to charge now; `balanceAmount` is
6
+ * what will still be owed under deposit policy (0 for full / clamped deposit).
7
+ */
8
+ export interface ResolvedPaymentAmount {
9
+ amountToPay: number;
10
+ kind: 'full' | 'deposit';
11
+ balanceAmount: number;
12
+ }
13
+ /**
14
+ * @internal
15
+ * Compute the deposit minor-unit amount from a `DepositAmount` spec. Percent
16
+ * floors `(base * value / 100)`; fixed amount is clamped to `base` to prevent
17
+ * collecting more than the line total. Caller is responsible for validating
18
+ * the spec (see {@link validatePaymentPolicy}).
19
+ */
20
+ export declare function computeDepositAmount(spec: DepositAmount, base: number): number;
21
+ /**
22
+ * @public
23
+ * Resolve a `PaymentPolicy` against a line gross total. Returns the immediate
24
+ * `amountToPay` (deposit when applicable), the `kind` for payment row tagging
25
+ * (`'full' | 'deposit'`), and the `balanceAmount` still owed. Null / `full`
26
+ * policies short-circuit to a normal full charge with `balanceAmount = 0`.
27
+ */
28
+ export declare function resolvePaymentAmount(policy: PaymentPolicy | null, lineGross: number): ResolvedPaymentAmount;
29
+ /**
30
+ * @internal
31
+ * Validate a `PaymentPolicy` configuration. Throws a `RangeError` when the
32
+ * spec is structurally invalid (percent out of (0, 100], amount ≤ 0, etc).
33
+ * Called from `upsertShopData` before persisting the policy on the product.
34
+ */
35
+ export declare function validatePaymentPolicy(policy: PaymentPolicy): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @internal
3
+ * Compute the deposit minor-unit amount from a `DepositAmount` spec. Percent
4
+ * floors `(base * value / 100)`; fixed amount is clamped to `base` to prevent
5
+ * collecting more than the line total. Caller is responsible for validating
6
+ * the spec (see {@link validatePaymentPolicy}).
7
+ */
8
+ export function computeDepositAmount(spec, base) {
9
+ if (spec.type === 'percent') {
10
+ return Math.floor((base * spec.value) / 100);
11
+ }
12
+ return Math.min(spec.value, base);
13
+ }
14
+ /**
15
+ * @public
16
+ * Resolve a `PaymentPolicy` against a line gross total. Returns the immediate
17
+ * `amountToPay` (deposit when applicable), the `kind` for payment row tagging
18
+ * (`'full' | 'deposit'`), and the `balanceAmount` still owed. Null / `full`
19
+ * policies short-circuit to a normal full charge with `balanceAmount = 0`.
20
+ */
21
+ export function resolvePaymentAmount(policy, lineGross) {
22
+ if (!policy || policy.type === 'full') {
23
+ return { amountToPay: lineGross, kind: 'full', balanceAmount: 0 };
24
+ }
25
+ const amountToPay = computeDepositAmount(policy.depositAmount, lineGross);
26
+ return {
27
+ amountToPay,
28
+ kind: 'deposit',
29
+ balanceAmount: Math.max(0, lineGross - amountToPay)
30
+ };
31
+ }
32
+ /**
33
+ * @internal
34
+ * Validate a `PaymentPolicy` configuration. Throws a `RangeError` when the
35
+ * spec is structurally invalid (percent out of (0, 100], amount ≤ 0, etc).
36
+ * Called from `upsertShopData` before persisting the policy on the product.
37
+ */
38
+ export function validatePaymentPolicy(policy) {
39
+ if (policy.type === 'full')
40
+ return;
41
+ const d = policy.depositAmount;
42
+ if (d.type === 'percent') {
43
+ if (!Number.isFinite(d.value) || d.value <= 0 || d.value > 100) {
44
+ throw new RangeError('Deposit percent must be in (0, 100].');
45
+ }
46
+ return;
47
+ }
48
+ if (d.type === 'amount') {
49
+ if (!Number.isInteger(d.value) || d.value <= 0) {
50
+ throw new RangeError('Deposit amount must be a positive integer (minor units).');
51
+ }
52
+ return;
53
+ }
54
+ throw new RangeError(`Unknown depositAmount.type: ${d.type}`);
55
+ }
@@ -0,0 +1,29 @@
1
+ import { shopPaymentsTable, type ShopPaymentKind } from '../../db-postgres/schema/shop/index.js';
2
+ export type ShopPaymentRow = typeof shopPaymentsTable.$inferSelect;
3
+ /**
4
+ * @internal
5
+ * Insert a new `shop_payments` row tagged with `kind` (`full | deposit |
6
+ * balance`). Called at checkout / balance link initiation, before the
7
+ * adapter takes the customer to the provider — `providerRef` may be null
8
+ * when the adapter is asynchronous about producing one.
9
+ */
10
+ export declare function insertPaymentRow(input: {
11
+ orderId: string;
12
+ provider: string;
13
+ providerRef?: string | null;
14
+ kind: ShopPaymentKind;
15
+ amount: number;
16
+ currency: string;
17
+ }): Promise<ShopPaymentRow>;
18
+ /** @internal Look up a payment row by provider + providerRef (webhook entry point). */
19
+ export declare function findPaymentByProviderRef(provider: string, providerRef: string): Promise<ShopPaymentRow | null>;
20
+ /** @internal Mark a payment row as paid (idempotent — no-op if already paid). */
21
+ export declare function markPaymentPaid(paymentId: string): Promise<void>;
22
+ /** @internal Mark a payment row as failed (rejected webhook). */
23
+ export declare function markPaymentFailed(paymentId: string): Promise<void>;
24
+ /**
25
+ * @internal
26
+ * List payment rows for an order, ordered by createdAt ascending. Used by
27
+ * refundOrder() when distinguishing which row to refund under per-kind refund.
28
+ */
29
+ export declare function listOrderPayments(orderId: string): Promise<ShopPaymentRow[]>;
@@ -0,0 +1,64 @@
1
+ import { and, eq } from 'drizzle-orm';
2
+ import { shopPaymentsTable } from '../../db-postgres/schema/shop/index.js';
3
+ import { getShopDb } from './db.js';
4
+ /**
5
+ * @internal
6
+ * Insert a new `shop_payments` row tagged with `kind` (`full | deposit |
7
+ * balance`). Called at checkout / balance link initiation, before the
8
+ * adapter takes the customer to the provider — `providerRef` may be null
9
+ * when the adapter is asynchronous about producing one.
10
+ */
11
+ export async function insertPaymentRow(input) {
12
+ const db = getShopDb();
13
+ const [row] = await db
14
+ .insert(shopPaymentsTable)
15
+ .values({
16
+ orderId: input.orderId,
17
+ provider: input.provider,
18
+ providerRef: input.providerRef ?? null,
19
+ kind: input.kind,
20
+ amount: input.amount,
21
+ currency: input.currency,
22
+ status: 'pending'
23
+ })
24
+ .returning();
25
+ return row;
26
+ }
27
+ /** @internal Look up a payment row by provider + providerRef (webhook entry point). */
28
+ export async function findPaymentByProviderRef(provider, providerRef) {
29
+ const db = getShopDb();
30
+ const [row] = await db
31
+ .select()
32
+ .from(shopPaymentsTable)
33
+ .where(and(eq(shopPaymentsTable.provider, provider), eq(shopPaymentsTable.providerRef, providerRef)));
34
+ return row ?? null;
35
+ }
36
+ /** @internal Mark a payment row as paid (idempotent — no-op if already paid). */
37
+ export async function markPaymentPaid(paymentId) {
38
+ const db = getShopDb();
39
+ await db
40
+ .update(shopPaymentsTable)
41
+ .set({ status: 'paid', updatedAt: new Date() })
42
+ .where(eq(shopPaymentsTable.id, paymentId));
43
+ }
44
+ /** @internal Mark a payment row as failed (rejected webhook). */
45
+ export async function markPaymentFailed(paymentId) {
46
+ const db = getShopDb();
47
+ await db
48
+ .update(shopPaymentsTable)
49
+ .set({ status: 'failed', updatedAt: new Date() })
50
+ .where(eq(shopPaymentsTable.id, paymentId));
51
+ }
52
+ /**
53
+ * @internal
54
+ * List payment rows for an order, ordered by createdAt ascending. Used by
55
+ * refundOrder() when distinguishing which row to refund under per-kind refund.
56
+ */
57
+ export async function listOrderPayments(orderId) {
58
+ const db = getShopDb();
59
+ return db
60
+ .select()
61
+ .from(shopPaymentsTable)
62
+ .where(eq(shopPaymentsTable.orderId, orderId))
63
+ .orderBy(shopPaymentsTable.createdAt);
64
+ }
@@ -12,7 +12,7 @@ export interface PopulatedShopField {
12
12
  /** Netto delta w PLN (number, ≤6dp). Od 0.15.2. */
13
13
  priceDelta: number;
14
14
  stock: number | null;
15
- attributes: Record<string, string> | null;
15
+ attributes: Record<string, unknown> | null;
16
16
  }>;
17
17
  }
18
18
  export declare function resolveShopFields(data: Record<string, unknown>, fields: Field[], ctx: PopulateCtx): Promise<Record<string, unknown>>;
@@ -1,9 +1,9 @@
1
- import { shopRefundsTable } from '../../db-postgres/schema/shop/index.js';
1
+ import { shopRefundsTable, type ShopPaymentKind } from '../../db-postgres/schema/shop/index.js';
2
2
  export type ShopRefundRow = typeof shopRefundsTable.$inferSelect;
3
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';
4
+ readonly code: 'order_not_found' | 'order_not_paid' | 'no_provider_ref' | 'unknown_provider' | 'refund_unsupported' | 'invalid_amount' | 'amount_exceeds_remaining' | 'provider_error' | 'no_payment_kind';
5
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);
6
+ constructor(code: 'order_not_found' | 'order_not_paid' | 'no_provider_ref' | 'unknown_provider' | 'refund_unsupported' | 'invalid_amount' | 'amount_exceeds_remaining' | 'provider_error' | 'no_payment_kind', message: string, cause?: unknown | undefined);
7
7
  }
8
8
  export interface RefundOrderInput {
9
9
  orderId: string;
@@ -11,6 +11,20 @@ export interface RefundOrderInput {
11
11
  amount?: number;
12
12
  reason?: string;
13
13
  createdBy?: string;
14
+ /**
15
+ * Which payment row to refund under deposit `paymentPolicy`. Defaults to
16
+ * `'full'`. When the order has only a `deposit` or `balance` row, that
17
+ * row is used regardless of this hint (back-compat). Per-kind refunds
18
+ * cap `amount` to the matched payment row's amount, never the full
19
+ * order total.
20
+ */
21
+ kind?: ShopPaymentKind;
22
+ /**
23
+ * Re-increment variant stock for each order item when the refund
24
+ * succeeds. Default `false` to preserve legacy refund behavior; set
25
+ * `true` from the admin UI when the goods are not being delivered.
26
+ */
27
+ releaseStock?: boolean;
14
28
  }
15
29
  export interface RefundOrderResult {
16
30
  refund: ShopRefundRow;
@@ -20,13 +34,4 @@ export interface RefundOrderResult {
20
34
  /** Sum of succeeded refunds for the given order, in minor units. */
21
35
  export declare function getRefundedAmount(orderId: string): Promise<number>;
22
36
  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
37
  export declare function refundOrder(input: RefundOrderInput): Promise<RefundOrderResult>;
@@ -1,7 +1,8 @@
1
- import { and, eq, sum } from 'drizzle-orm';
2
- import { shopOrdersTable, shopRefundsTable } from '../../db-postgres/schema/shop/index.js';
1
+ import { and, eq, sql, sum } from 'drizzle-orm';
2
+ import { shopOrderItemsTable, shopProductVariantsTable, shopRefundsTable } from '../../db-postgres/schema/shop/index.js';
3
3
  import { getShopDb, requireShopConfig } from './db.js';
4
4
  import { getOrderById, updateOrderStatus } from './orders.js';
5
+ import { listOrderPayments } from './payments.js';
5
6
  export class RefundError extends Error {
6
7
  code;
7
8
  cause;
@@ -46,6 +47,42 @@ function findAdapter(provider) {
46
47
  * `pending` refund row is marked `failed` on provider error so admin can see
47
48
  * what was attempted.
48
49
  */
50
+ async function resolveTargetPayment(orderId, requestedKind) {
51
+ const payments = await listOrderPayments(orderId);
52
+ const paid = payments.filter((p) => p.status === 'paid');
53
+ if (requestedKind) {
54
+ const match = paid.find((p) => p.kind === requestedKind);
55
+ if (!match) {
56
+ throw new RefundError('no_payment_kind', `No paid ${requestedKind} payment row on order to refund`);
57
+ }
58
+ return { payment: match, kind: requestedKind };
59
+ }
60
+ // No explicit kind: prefer a `full` row; with exactly one paid row of any
61
+ // kind, derive from it (keeps single-payment-row orders refunding their
62
+ // only paid payment). Legacy orders with no shop_payments rows fall back
63
+ // to the `'full'` path that uses order.paymentProviderRef.
64
+ const full = paid.find((p) => p.kind === 'full');
65
+ if (full)
66
+ return { payment: full, kind: 'full' };
67
+ if (paid.length === 1)
68
+ return { payment: paid[0], kind: paid[0].kind };
69
+ return { payment: null, kind: 'full' };
70
+ }
71
+ async function releaseOrderStock(orderId) {
72
+ const db = getShopDb();
73
+ const items = await db
74
+ .select()
75
+ .from(shopOrderItemsTable)
76
+ .where(eq(shopOrderItemsTable.orderId, orderId));
77
+ for (const item of items) {
78
+ if (!item.variantId)
79
+ continue;
80
+ await db
81
+ .update(shopProductVariantsTable)
82
+ .set({ stock: sql `${shopProductVariantsTable.stock} + ${item.qty}` })
83
+ .where(and(eq(shopProductVariantsTable.id, item.variantId), sql `${shopProductVariantsTable.stock} IS NOT NULL`));
84
+ }
85
+ }
49
86
  export async function refundOrder(input) {
50
87
  const db = getShopDb();
51
88
  const order = await getOrderById(input.orderId);
@@ -60,9 +97,6 @@ export async function refundOrder(input) {
60
97
  if (!order.paymentMethod) {
61
98
  throw new RefundError('unknown_provider', `Order ${order.number} has no payment method`);
62
99
  }
63
- if (!order.paymentProviderRef) {
64
- throw new RefundError('no_provider_ref', `Order ${order.number} has no payment provider reference`);
65
- }
66
100
  const adapter = findAdapter(order.paymentMethod);
67
101
  if (!adapter) {
68
102
  throw new RefundError('unknown_provider', `Payment provider "${order.paymentMethod}" is not configured`);
@@ -70,10 +104,23 @@ export async function refundOrder(input) {
70
104
  if (typeof adapter.refund !== 'function') {
71
105
  throw new RefundError('refund_unsupported', `Payment provider "${adapter.id}" does not support refunds`);
72
106
  }
73
- const alreadyRefunded = await getRefundedAmount(order.id);
74
- const remaining = order.totalGross - alreadyRefunded;
107
+ // Pick the payment row to target — explicit kind, single paid row, or
108
+ // legacy fallback to order.paymentProviderRef when no rows exist.
109
+ const target = await resolveTargetPayment(order.id, input.kind);
110
+ const providerRef = target.payment?.providerRef ?? order.paymentProviderRef;
111
+ if (!providerRef) {
112
+ throw new RefundError('no_provider_ref', `Order ${order.number} has no payment provider reference`);
113
+ }
114
+ // Per-kind cap: refund up to the matched row's amount (not order total)
115
+ // minus any prior refunds against that same payment row. Legacy path
116
+ // (no payment row) caps at order.totalGross.
117
+ const refundCeiling = target.payment ? target.payment.amount : order.totalGross;
118
+ const priorOnTarget = target.payment
119
+ ? await getRefundedAmountForPayment(target.payment.id)
120
+ : await getRefundedAmount(order.id);
121
+ const remaining = refundCeiling - priorOnTarget;
75
122
  if (remaining <= 0) {
76
- throw new RefundError('amount_exceeds_remaining', `Order ${order.number} already fully refunded`);
123
+ throw new RefundError('amount_exceeds_remaining', `Payment already fully refunded (${target.kind})`);
77
124
  }
78
125
  const amount = input.amount ?? remaining;
79
126
  if (!Number.isInteger(amount) || amount <= 0) {
@@ -86,6 +133,7 @@ export async function refundOrder(input) {
86
133
  .insert(shopRefundsTable)
87
134
  .values({
88
135
  orderId: order.id,
136
+ paymentId: target.payment?.id ?? null,
89
137
  provider: adapter.id,
90
138
  amount,
91
139
  currency: order.currency,
@@ -94,15 +142,15 @@ export async function refundOrder(input) {
94
142
  createdBy: input.createdBy ?? null
95
143
  })
96
144
  .returning();
97
- let providerRef = null;
145
+ let resultProviderRef = null;
98
146
  try {
99
147
  const providerResult = await adapter.refund({
100
- providerRef: order.paymentProviderRef,
148
+ providerRef,
101
149
  amount,
102
150
  currency: order.currency,
103
151
  reason: input.reason
104
152
  });
105
- providerRef = providerResult.providerRef;
153
+ resultProviderRef = providerResult.providerRef;
106
154
  }
107
155
  catch (err) {
108
156
  await db
@@ -118,23 +166,58 @@ export async function refundOrder(input) {
118
166
  .update(shopRefundsTable)
119
167
  .set({
120
168
  status: 'succeeded',
121
- providerRef,
169
+ providerRef: resultProviderRef,
122
170
  updatedAt: new Date()
123
171
  })
124
172
  .where(eq(shopRefundsTable.id, pending.id))
125
173
  .returning();
126
174
  const newRemaining = remaining - amount;
127
175
  let orderStatusChanged = false;
128
- if (newRemaining === 0) {
176
+ // Transition order to `refunded` only when the cumulative refund across
177
+ // ALL paid payment rows equals the total collected. For deposit-only
178
+ // orders pre-balance the "collected" = deposit row.amount; with balance
179
+ // paid it's deposit+balance.
180
+ const allCollected = await getCollectedAmount(order.id, order.totalGross);
181
+ const totalRefundedAfter = (await getRefundedAmount(order.id)) + 0; // succeeded row already counted
182
+ if (totalRefundedAfter >= allCollected) {
129
183
  await updateOrderStatus(order.id, 'refunded', {
130
184
  note: `Refund ${amount}${input.reason ? ` — ${input.reason}` : ''}`,
131
185
  changedBy: input.createdBy ?? 'admin'
132
186
  });
133
187
  orderStatusChanged = true;
134
188
  }
189
+ if (input.releaseStock) {
190
+ await releaseOrderStock(order.id);
191
+ }
135
192
  return {
136
193
  refund: succeeded,
137
194
  remainingRefundable: newRemaining,
138
195
  orderStatusChanged
139
196
  };
140
197
  }
198
+ /** @internal Sum of succeeded refunds attached to a specific payment row. */
199
+ async function getRefundedAmountForPayment(paymentId) {
200
+ const db = getShopDb();
201
+ const [row] = await db
202
+ .select({ total: sum(shopRefundsTable.amount) })
203
+ .from(shopRefundsTable)
204
+ .where(and(eq(shopRefundsTable.paymentId, paymentId), eq(shopRefundsTable.status, 'succeeded')));
205
+ const raw = row?.total;
206
+ if (raw == null)
207
+ return 0;
208
+ const n = typeof raw === 'string' ? Number(raw) : raw;
209
+ return Number.isFinite(n) ? n : 0;
210
+ }
211
+ /**
212
+ * @internal
213
+ * Total collected amount on an order — sum of `paid` payment row amounts
214
+ * when shop_payments rows exist; falls back to `order.totalGross` for
215
+ * legacy orders (no rows). Used to decide when to transition to `refunded`.
216
+ */
217
+ async function getCollectedAmount(orderId, fallback) {
218
+ const payments = await listOrderPayments(orderId);
219
+ const paid = payments.filter((p) => p.status === 'paid');
220
+ if (paid.length === 0)
221
+ return fallback;
222
+ return paid.reduce((sum, p) => sum + p.amount, 0);
223
+ }
@@ -1,4 +1,5 @@
1
1
  import { shopProductsTable, shopProductVariantsTable } from '../../db-postgres/schema/shop/index.js';
2
+ import type { PaymentPolicy } from '../types.js';
2
3
  type RawShopDataRow = typeof shopProductsTable.$inferSelect;
3
4
  type RawVariantRow = typeof shopProductVariantsTable.$inferSelect;
4
5
  export type ShopDataRow = Omit<RawShopDataRow, 'basePrice'> & {
@@ -15,6 +16,8 @@ export interface ShopDataInput {
15
16
  vatRate: number;
16
17
  isActive?: boolean;
17
18
  sortOrder?: number | null;
19
+ /** Per-product payment policy. Null = full payment (legacy). */
20
+ paymentPolicy?: PaymentPolicy | null;
18
21
  }
19
22
  export interface VariantInput {
20
23
  id?: string;
@@ -22,7 +25,7 @@ export interface VariantInput {
22
25
  name?: Record<string, string> | null;
23
26
  priceDelta?: number;
24
27
  stock?: number | null;
25
- attributes?: Record<string, string> | null;
28
+ attributes?: Record<string, unknown> | null;
26
29
  }
27
30
  declare function validateShopData(input: ShopDataInput): void;
28
31
  export declare function getShopDataByEntry(entryId: string): Promise<ShopDataWithVariants | null>;