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.
- package/API.md +58 -2
- package/CHANGELOG.md +105 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +8 -0
- package/dist/admin/auth-client.d.ts +42 -42
- package/dist/admin/client/admin/admin-layout.svelte +12 -2
- package/dist/admin/client/admin/admin-layout.svelte.d.ts +2 -1
- package/dist/admin/client/collection/data-table.svelte +0 -39
- package/dist/admin/client/collection/data-table.svelte.d.ts +0 -2
- package/dist/admin/client/shop/coupon-schema.d.ts +1 -1
- package/dist/admin/client/shop/refund-dialog.svelte +37 -1
- package/dist/admin/client/shop/refund-dialog.svelte.d.ts +3 -0
- package/dist/admin/client/shop/shop-order-detail-page.svelte +192 -0
- package/dist/admin/components/fields/field-renderer.svelte +6 -1
- package/dist/admin/components/fields/icon-field.svelte +86 -0
- package/dist/admin/components/fields/icon-field.svelte.d.ts +8 -0
- package/dist/admin/components/fields/icon-picker-dialog.svelte +174 -0
- package/dist/admin/components/fields/icon-picker-dialog.svelte.d.ts +11 -0
- package/dist/admin/components/fields/object-field.svelte +27 -7
- package/dist/admin/components/fields/shop-field.svelte +210 -20
- package/dist/admin/components/layout/layout-tabs.svelte +1 -0
- package/dist/admin/components/variant-form/VariantAttributeRenderer.svelte +109 -0
- package/dist/admin/components/variant-form/VariantAttributeRenderer.svelte.d.ts +9 -0
- package/dist/admin/helpers/build-icon-set-map.d.ts +8 -0
- package/dist/admin/helpers/build-icon-set-map.js +16 -0
- package/dist/admin/helpers/index.d.ts +2 -0
- package/dist/admin/helpers/index.js +2 -0
- package/dist/admin/remote/shop.remote.d.ts +116 -24
- package/dist/admin/remote/shop.remote.js +79 -6
- package/dist/admin/state/icon-sets.svelte.d.ts +9 -0
- package/dist/admin/state/icon-sets.svelte.js +20 -0
- package/dist/cli/scaffold/admin.js +2 -2
- package/dist/components/ui/checkbox/checkbox.svelte +3 -3
- package/dist/core/cms.d.ts +11 -2
- package/dist/core/cms.js +29 -0
- package/dist/core/fields/fieldSchemaToTs.js +7 -0
- package/dist/core/server/generator/fields.d.ts +2 -0
- package/dist/core/server/generator/fields.js +34 -1
- package/dist/core/server/generator/generator.js +2 -1
- package/dist/db-postgres/schema/shop/index.d.ts +1 -0
- package/dist/db-postgres/schema/shop/index.js +1 -0
- package/dist/db-postgres/schema/shop/invoice.d.ts +254 -0
- package/dist/db-postgres/schema/shop/invoice.js +27 -0
- package/dist/db-postgres/schema/shop/order.d.ts +107 -1
- package/dist/db-postgres/schema/shop/order.js +7 -1
- package/dist/db-postgres/schema/shop/payment.d.ts +20 -0
- package/dist/db-postgres/schema/shop/payment.js +4 -1
- package/dist/db-postgres/schema/shop/product.d.ts +20 -0
- package/dist/db-postgres/schema/shop/product.js +3 -1
- package/dist/db-postgres/schema/shop/productVariant.d.ts +12 -2
- package/dist/db-postgres/schema/shop/productVariant.js +22 -0
- package/dist/paraglide/messages/_index.d.ts +36 -3
- package/dist/paraglide/messages/_index.js +71 -3
- package/dist/paraglide/messages/en.d.ts +5 -0
- package/dist/paraglide/messages/en.js +14 -0
- package/dist/paraglide/messages/pl.d.ts +5 -0
- package/dist/paraglide/messages/pl.js +14 -0
- package/dist/shop/adapters/fakturownia/client.d.ts +28 -0
- package/dist/shop/adapters/fakturownia/client.js +67 -0
- package/dist/shop/adapters/fakturownia/index.d.ts +27 -0
- package/dist/shop/adapters/fakturownia/index.js +36 -0
- package/dist/shop/adapters/fakturownia/payload.d.ts +35 -0
- package/dist/shop/adapters/fakturownia/payload.js +45 -0
- package/dist/shop/cart/types.d.ts +1 -0
- package/dist/shop/client/index.d.ts +61 -0
- package/dist/shop/client/index.js +5 -1
- package/dist/shop/expiry.d.ts +35 -0
- package/dist/shop/expiry.js +68 -0
- package/dist/shop/http/balance-handler.d.ts +20 -0
- package/dist/shop/http/balance-handler.js +91 -0
- package/dist/shop/http/cart-handler.js +19 -0
- package/dist/shop/http/checkout-handler.js +30 -1
- package/dist/shop/http/index.d.ts +2 -0
- package/dist/shop/http/index.js +2 -0
- package/dist/shop/http/upcoming-handler.d.ts +16 -0
- package/dist/shop/http/upcoming-handler.js +65 -0
- package/dist/shop/http/webhook-handler.js +46 -9
- package/dist/shop/index.d.ts +7 -1
- package/dist/shop/index.js +10 -1
- package/dist/shop/nip.d.ts +12 -0
- package/dist/shop/nip.js +23 -0
- package/dist/shop/server/balance-payment.d.ts +40 -0
- package/dist/shop/server/balance-payment.js +140 -0
- package/dist/shop/server/cart-hydrate.js +2 -0
- package/dist/shop/server/init.d.ts +14 -0
- package/dist/shop/server/init.js +35 -0
- package/dist/shop/server/invoices.d.ts +64 -0
- package/dist/shop/server/invoices.js +237 -0
- package/dist/shop/server/orders.d.ts +38 -0
- package/dist/shop/server/orders.js +152 -2
- package/dist/shop/server/payment-policy.d.ts +35 -0
- package/dist/shop/server/payment-policy.js +55 -0
- package/dist/shop/server/payments.d.ts +29 -0
- package/dist/shop/server/payments.js +64 -0
- package/dist/shop/server/populate.d.ts +1 -1
- package/dist/shop/server/refund.d.ts +17 -12
- package/dist/shop/server/refund.js +96 -13
- package/dist/shop/server/shop-data.d.ts +4 -1
- package/dist/shop/server/shop-data.js +24 -2
- package/dist/shop/template.d.ts +13 -0
- package/dist/shop/template.js +98 -0
- package/dist/shop/types.d.ts +208 -1
- package/dist/shop/variant-attributes.d.ts +28 -0
- package/dist/shop/variant-attributes.js +69 -0
- package/dist/sveltekit/server/index.d.ts +1 -0
- package/dist/sveltekit/server/index.js +2 -0
- package/dist/types/cms.d.ts +4 -3
- package/dist/types/cms.schema.d.ts +1 -1
- package/dist/types/cms.schema.js +9 -0
- package/dist/types/fields.d.ts +21 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/plugins.d.ts +40 -0
- package/dist/types/plugins.js +4 -1
- package/dist/updates/0.26.1/index.d.ts +2 -0
- package/dist/updates/0.26.1/index.js +19 -0
- package/dist/updates/0.27.0/index.d.ts +2 -0
- package/dist/updates/0.27.0/index.js +50 -0
- package/dist/updates/0.28.0/index.d.ts +2 -0
- package/dist/updates/0.28.0/index.js +38 -0
- package/dist/updates/index.js +7 -1
- package/package.json +1 -1
- package/dist/paraglide/messages/hello_world.d.ts +0 -5
- package/dist/paraglide/messages/hello_world.js +0 -33
- package/dist/paraglide/messages/login_hello.d.ts +0 -16
- package/dist/paraglide/messages/login_hello.js +0 -34
- package/dist/paraglide/messages/login_please_login.d.ts +0 -16
- package/dist/paraglide/messages/login_please_login.js +0 -34
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
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', `
|
|
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
|
|
145
|
+
let resultProviderRef = null;
|
|
98
146
|
try {
|
|
99
147
|
const providerResult = await adapter.refund({
|
|
100
|
-
providerRef
|
|
148
|
+
providerRef,
|
|
101
149
|
amount,
|
|
102
150
|
currency: order.currency,
|
|
103
151
|
reason: input.reason
|
|
104
152
|
});
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
|
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>;
|
|
@@ -2,7 +2,10 @@ import { asc, eq, inArray } from 'drizzle-orm';
|
|
|
2
2
|
import { shopProductsTable, shopProductVariantsTable } from '../../db-postgres/schema/shop/index.js';
|
|
3
3
|
import { entriesTable } from '../../db-postgres/schema/entry.js';
|
|
4
4
|
import { entryVersionsTable } from '../../db-postgres/schema/entryVersion.js';
|
|
5
|
+
import { getCMS } from '../../core/cms.js';
|
|
5
6
|
import { getShopDb } from './db.js';
|
|
7
|
+
import { validateVariantAttributes } from '../variant-attributes.js';
|
|
8
|
+
import { validatePaymentPolicy } from './payment-policy.js';
|
|
6
9
|
const MAX_PLN = 1e9;
|
|
7
10
|
function validateShopData(input) {
|
|
8
11
|
if (!Number.isFinite(input.basePrice) || input.basePrice < 0 || input.basePrice > MAX_PLN) {
|
|
@@ -11,6 +14,8 @@ function validateShopData(input) {
|
|
|
11
14
|
if (!Number.isInteger(input.vatRate) || input.vatRate < 0 || input.vatRate > 100) {
|
|
12
15
|
throw new Error('vatRate must be an integer between 0 and 100.');
|
|
13
16
|
}
|
|
17
|
+
if (input.paymentPolicy != null)
|
|
18
|
+
validatePaymentPolicy(input.paymentPolicy);
|
|
14
19
|
}
|
|
15
20
|
function mapShopRow(r) {
|
|
16
21
|
return { ...r, basePrice: Number(r.basePrice) };
|
|
@@ -40,13 +45,18 @@ export async function upsertShopData(entryId, input, variants) {
|
|
|
40
45
|
let productId;
|
|
41
46
|
const basePriceSql = String(input.basePrice);
|
|
42
47
|
if (existing) {
|
|
48
|
+
// `paymentPolicy === undefined` (caller didn't include the key) preserves
|
|
49
|
+
// the existing policy — admin clients that don't surface the field
|
|
50
|
+
// must not silently wipe it. Explicit `null` clears.
|
|
51
|
+
const nextPolicy = input.paymentPolicy === undefined ? existing.paymentPolicy : input.paymentPolicy;
|
|
43
52
|
const [updated] = await db
|
|
44
53
|
.update(shopProductsTable)
|
|
45
54
|
.set({
|
|
46
55
|
basePrice: basePriceSql,
|
|
47
56
|
vatRate: input.vatRate,
|
|
48
57
|
isActive: input.isActive ?? existing.isActive,
|
|
49
|
-
sortOrder: input.sortOrder ?? existing.sortOrder,
|
|
58
|
+
sortOrder: input.sortOrder ?? existing.sortOrder ?? null,
|
|
59
|
+
paymentPolicy: nextPolicy,
|
|
50
60
|
updatedAt: new Date()
|
|
51
61
|
})
|
|
52
62
|
.where(eq(shopProductsTable.entryId, entryId))
|
|
@@ -61,12 +71,24 @@ export async function upsertShopData(entryId, input, variants) {
|
|
|
61
71
|
basePrice: basePriceSql,
|
|
62
72
|
vatRate: input.vatRate,
|
|
63
73
|
isActive: input.isActive ?? true,
|
|
64
|
-
sortOrder: input.sortOrder
|
|
74
|
+
sortOrder: input.sortOrder ?? null,
|
|
75
|
+
paymentPolicy: input.paymentPolicy ?? null
|
|
65
76
|
})
|
|
66
77
|
.returning();
|
|
67
78
|
productId = created.id;
|
|
68
79
|
}
|
|
69
80
|
if (variants !== undefined) {
|
|
81
|
+
// Validate against the schema declared in defineShop({ variantAttributes }).
|
|
82
|
+
// Missing/extra keys, type mismatches → InvalidVariantAttributesError
|
|
83
|
+
// (code INVALID_VARIANT_ATTRIBUTES). Skipped if no shop config or empty
|
|
84
|
+
// schema (legacy untyped behavior preserved).
|
|
85
|
+
const shopConfig = getCMS().shopConfig;
|
|
86
|
+
const attrSchema = shopConfig?.variantAttributes ?? {};
|
|
87
|
+
if (Object.keys(attrSchema).length > 0) {
|
|
88
|
+
for (const v of variants) {
|
|
89
|
+
validateVariantAttributes(v.attributes, attrSchema);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
70
92
|
const submittedIds = new Set(variants.filter((v) => v.id).map((v) => v.id));
|
|
71
93
|
const currentVariants = existing?.variants ?? [];
|
|
72
94
|
for (const v of currentVariants) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String interpolation engine used by `defineShop({ variantLabel.template })`
|
|
3
|
+
* to render variant names from typed `variantAttributes`.
|
|
4
|
+
*
|
|
5
|
+
* Syntax: `{key}` or `{key|filter}` or `{key|filter:arg}`.
|
|
6
|
+
* Supported filters: `date` (long|medium|short), `currency` (PLN by default),
|
|
7
|
+
* `uppercase`. Unknown filter → value passes through unchanged. Unknown key
|
|
8
|
+
* → empty string + `console.warn` in dev. Malformed template (unclosed brace)
|
|
9
|
+
* → raw template + warn.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export declare function interpolateTemplate(template: string, vars: Record<string, unknown>, locale: string): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String interpolation engine used by `defineShop({ variantLabel.template })`
|
|
3
|
+
* to render variant names from typed `variantAttributes`.
|
|
4
|
+
*
|
|
5
|
+
* Syntax: `{key}` or `{key|filter}` or `{key|filter:arg}`.
|
|
6
|
+
* Supported filters: `date` (long|medium|short), `currency` (PLN by default),
|
|
7
|
+
* `uppercase`. Unknown filter → value passes through unchanged. Unknown key
|
|
8
|
+
* → empty string + `console.warn` in dev. Malformed template (unclosed brace)
|
|
9
|
+
* → raw template + warn.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export function interpolateTemplate(template, vars, locale) {
|
|
14
|
+
if (!hasBalancedBraces(template)) {
|
|
15
|
+
console.warn(`[interpolateTemplate] Malformed template (unclosed brace): ${template}`);
|
|
16
|
+
return template;
|
|
17
|
+
}
|
|
18
|
+
return template.replace(/\{([^}]+)\}/g, (_match, body) => {
|
|
19
|
+
const { key, filter, arg } = parsePlaceholder(body);
|
|
20
|
+
if (!(key in vars)) {
|
|
21
|
+
console.warn(`[interpolateTemplate] Unknown key "${key}" in template: ${template}`);
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
const raw = vars[key];
|
|
25
|
+
if (raw === null || raw === undefined)
|
|
26
|
+
return '';
|
|
27
|
+
if (!filter)
|
|
28
|
+
return String(raw);
|
|
29
|
+
return applyFilter(raw, filter, arg, locale);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** @internal */
|
|
33
|
+
function parsePlaceholder(body) {
|
|
34
|
+
const [keyPart, filterPart] = body.split('|', 2);
|
|
35
|
+
const key = keyPart.trim();
|
|
36
|
+
if (!filterPart)
|
|
37
|
+
return { key, filter: null, arg: null };
|
|
38
|
+
const [filterName, ...argParts] = filterPart.split(':');
|
|
39
|
+
return {
|
|
40
|
+
key,
|
|
41
|
+
filter: filterName.trim(),
|
|
42
|
+
arg: argParts.length > 0 ? argParts.join(':').trim() : null
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** @internal */
|
|
46
|
+
function applyFilter(value, filter, arg, locale) {
|
|
47
|
+
switch (filter) {
|
|
48
|
+
case 'date':
|
|
49
|
+
return formatDate(value, arg ?? 'medium', locale);
|
|
50
|
+
case 'currency':
|
|
51
|
+
return formatCurrency(value, arg ?? 'PLN', locale);
|
|
52
|
+
case 'uppercase':
|
|
53
|
+
return String(value).toUpperCase();
|
|
54
|
+
default:
|
|
55
|
+
return String(value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** @internal */
|
|
59
|
+
function formatDate(value, style, locale) {
|
|
60
|
+
const date = new Date(String(value));
|
|
61
|
+
if (Number.isNaN(date.getTime()))
|
|
62
|
+
return String(value);
|
|
63
|
+
const dateStyle = ['long', 'medium', 'short'].includes(style)
|
|
64
|
+
? style
|
|
65
|
+
: 'medium';
|
|
66
|
+
try {
|
|
67
|
+
return new Intl.DateTimeFormat(locale, { dateStyle }).format(date);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** @internal */
|
|
74
|
+
function formatCurrency(value, currency, locale) {
|
|
75
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
76
|
+
if (!Number.isFinite(num))
|
|
77
|
+
return String(value);
|
|
78
|
+
try {
|
|
79
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(num);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** @internal */
|
|
86
|
+
function hasBalancedBraces(template) {
|
|
87
|
+
let depth = 0;
|
|
88
|
+
for (const ch of template) {
|
|
89
|
+
if (ch === '{')
|
|
90
|
+
depth++;
|
|
91
|
+
else if (ch === '}') {
|
|
92
|
+
depth--;
|
|
93
|
+
if (depth < 0)
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return depth === 0;
|
|
98
|
+
}
|