@final-commerce/command-frame 0.8.0-staging.2 → 0.8.1-online.1
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/dist/CommonTypes.d.ts +14 -0
- package/dist/actions/attach-checkout-contact/action.d.ts +6 -0
- package/dist/actions/attach-checkout-contact/action.js +8 -0
- package/dist/actions/attach-checkout-contact/mock.d.ts +15 -0
- package/dist/actions/attach-checkout-contact/mock.js +22 -0
- package/dist/actions/attach-checkout-contact/types.d.ts +35 -0
- package/dist/actions/attach-checkout-contact/types.js +16 -0
- package/dist/actions/get-outlets/action.js +2 -2
- package/dist/actions/get-outlets/mock.d.ts +12 -0
- package/dist/actions/get-outlets/mock.js +28 -4
- package/dist/actions/get-outlets/types.d.ts +19 -1
- package/dist/actions/get-refund-plan/mock.d.ts +4 -0
- package/dist/actions/get-refund-plan/mock.js +8 -0
- package/dist/actions/get-refund-plan/mockRows.d.ts +6 -0
- package/dist/actions/get-refund-plan/mockRows.js +114 -0
- package/dist/actions/get-refund-plan/types.d.ts +89 -0
- package/dist/actions/resume-checkout/action.d.ts +6 -0
- package/dist/actions/resume-checkout/action.js +8 -0
- package/dist/actions/resume-checkout/mock.d.ts +16 -0
- package/dist/actions/resume-checkout/mock.js +51 -0
- package/dist/actions/resume-checkout/types.d.ts +59 -0
- package/dist/actions/resume-checkout/types.js +13 -0
- package/dist/actions/set-outlet/action.d.ts +6 -0
- package/dist/actions/set-outlet/action.js +8 -0
- package/dist/actions/set-outlet/mock.d.ts +13 -0
- package/dist/actions/set-outlet/mock.js +24 -0
- package/dist/actions/set-outlet/types.d.ts +31 -0
- package/dist/actions/set-outlet/types.js +21 -0
- package/dist/actions/start-checkout/action.d.ts +6 -0
- package/dist/actions/start-checkout/action.js +8 -0
- package/dist/actions/start-checkout/mock.d.ts +16 -0
- package/dist/actions/start-checkout/mock.js +80 -0
- package/dist/actions/start-checkout/types.d.ts +90 -0
- package/dist/actions/start-checkout/types.js +10 -0
- package/dist/demo/database.js +4 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +9 -0
- package/dist/projects/render/mocks.js +10 -0
- package/dist/projects/render/types.d.ts +16 -1
- package/dist/pubsub/topics/checkout/checkout-started/types.d.ts +26 -0
- package/dist/pubsub/topics/checkout/checkout-started/types.js +1 -0
- package/dist/pubsub/topics/checkout/index.d.ts +7 -0
- package/dist/pubsub/topics/checkout/index.js +28 -0
- package/dist/pubsub/topics/checkout/payment-completed/types.d.ts +31 -0
- package/dist/pubsub/topics/checkout/payment-completed/types.js +1 -0
- package/dist/pubsub/topics/checkout/payment-failed/types.d.ts +20 -0
- package/dist/pubsub/topics/checkout/payment-failed/types.js +1 -0
- package/dist/pubsub/topics/checkout/types.d.ts +12 -0
- package/dist/pubsub/topics/checkout/types.js +8 -0
- package/dist/pubsub/topics/index.d.ts +18 -17
- package/dist/pubsub/topics/index.js +18 -17
- package/dist/pubsub/topics/types.d.ts +8 -0
- package/package.json +2 -2
package/dist/CommonTypes.d.ts
CHANGED
|
@@ -181,9 +181,23 @@ export interface CFOutletInfo {
|
|
|
181
181
|
state?: string;
|
|
182
182
|
postCode?: string;
|
|
183
183
|
};
|
|
184
|
+
address2?: string;
|
|
184
185
|
city?: string;
|
|
185
186
|
state?: string;
|
|
186
187
|
country?: string;
|
|
188
|
+
postCode?: string;
|
|
189
|
+
phone?: string;
|
|
190
|
+
/** Short display alias, when the merchant set one (e.g. `DTWN`). */
|
|
191
|
+
alias?: string;
|
|
192
|
+
/**
|
|
193
|
+
* Whether this location can take an ONLINE payment. Storefront only — it is
|
|
194
|
+
* absent on a register, where it has no meaning.
|
|
195
|
+
*
|
|
196
|
+
* `false` means the outlet has no completed payment connection, so starting a
|
|
197
|
+
* checkout against it WILL fail. A pickup picker must not offer it as a
|
|
198
|
+
* selectable location, or must show it as unavailable.
|
|
199
|
+
*/
|
|
200
|
+
connected?: boolean;
|
|
187
201
|
}
|
|
188
202
|
export interface CFContextManage {
|
|
189
203
|
user: unknown;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach Checkout Contact action
|
|
3
|
+
* Calls the attachCheckoutContact action on the parent window
|
|
4
|
+
*/
|
|
5
|
+
import { commandFrameClient } from '../../client';
|
|
6
|
+
export const attachCheckoutContact = async (params) => {
|
|
7
|
+
return await commandFrameClient.call('attachCheckoutContact', params);
|
|
8
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AttachCheckoutContact } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Standalone mock of attaching contact to an online order.
|
|
4
|
+
*
|
|
5
|
+
* It mirrors the one REFUSAL the real command makes — a missing email — because
|
|
6
|
+
* that is the mistake a builder actually hits, and a mock that accepted anything
|
|
7
|
+
* would let a checkout screen look finished and lose receipts on the published
|
|
8
|
+
* site.
|
|
9
|
+
*
|
|
10
|
+
* It does NOT mirror "no order to attach to". The real command answers
|
|
11
|
+
* `attached: false` there rather than throwing, precisely so a flow can call it
|
|
12
|
+
* optimistically and still let the shopper pay; the mock always has a checkout
|
|
13
|
+
* to attach to, so it reports `true`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const mockAttachCheckoutContact: AttachCheckoutContact;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone mock of attaching contact to an online order.
|
|
3
|
+
*
|
|
4
|
+
* It mirrors the one REFUSAL the real command makes — a missing email — because
|
|
5
|
+
* that is the mistake a builder actually hits, and a mock that accepted anything
|
|
6
|
+
* would let a checkout screen look finished and lose receipts on the published
|
|
7
|
+
* site.
|
|
8
|
+
*
|
|
9
|
+
* It does NOT mirror "no order to attach to". The real command answers
|
|
10
|
+
* `attached: false` there rather than throwing, precisely so a flow can call it
|
|
11
|
+
* optimistically and still let the shopper pay; the mock always has a checkout
|
|
12
|
+
* to attach to, so it reports `true`.
|
|
13
|
+
*/
|
|
14
|
+
export const mockAttachCheckoutContact = async (params) => {
|
|
15
|
+
console.log('[Mock] attachCheckoutContact called', params);
|
|
16
|
+
if (!params)
|
|
17
|
+
throw new Error('Params required');
|
|
18
|
+
if (!params.email || !params.email.trim()) {
|
|
19
|
+
throw new Error('attachCheckoutContact: email is required — it is where the receipt goes');
|
|
20
|
+
}
|
|
21
|
+
return { attached: true, timestamp: new Date().toISOString() };
|
|
22
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guest contact for the order. There is no shopper account, so this is the only
|
|
3
|
+
* way to reach them — `email` is where the receipt goes and is required.
|
|
4
|
+
*/
|
|
5
|
+
export interface AttachCheckoutContactParams {
|
|
6
|
+
email: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
phone?: string;
|
|
9
|
+
/** Defaults to the outlet the storefront booted against. */
|
|
10
|
+
outletId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface AttachCheckoutContactResponse {
|
|
13
|
+
/**
|
|
14
|
+
* `false` means there was nothing to attach to — no checkout had been started
|
|
15
|
+
* for this cart. It is NOT a failure to record the contact on an order that
|
|
16
|
+
* exists, and it never means the shopper was charged.
|
|
17
|
+
*/
|
|
18
|
+
attached: boolean;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Put the shopper's contact on the order `startCheckout` already created.
|
|
23
|
+
*
|
|
24
|
+
* Call it BEFORE paying, whenever the email is known — on blur, on submit, or
|
|
25
|
+
* as the shopper leaves the contact step. It reuses the order's idempotency
|
|
26
|
+
* key, so it updates that order rather than creating a second one.
|
|
27
|
+
*
|
|
28
|
+
* SAFE TO CALL TWICE. The server only ever ADDS contact to an order that has
|
|
29
|
+
* none, so a later call cannot redirect a receipt that was already addressed.
|
|
30
|
+
*
|
|
31
|
+
* NEVER THROWS FOR A MISSING ORDER. Failing to record an email must not stop a
|
|
32
|
+
* payment the shopper is trying to make, so the caller gets `attached: false`
|
|
33
|
+
* and can carry on to pay.
|
|
34
|
+
*/
|
|
35
|
+
export type AttachCheckoutContact = (params: AttachCheckoutContactParams) => Promise<AttachCheckoutContactResponse>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Attach Checkout Contact Types
|
|
2
|
+
//
|
|
3
|
+
// The second half of the online checkout. `startCheckout` creates the order and
|
|
4
|
+
// mounts the payment fields the moment a shopper ARRIVES; this puts their email
|
|
5
|
+
// on that same order once they have typed it.
|
|
6
|
+
//
|
|
7
|
+
// WHY THESE ARE TWO COMMANDS. The card fields cannot render without a payment
|
|
8
|
+
// session, a session needs a server-priced amount, and an amount needs an order.
|
|
9
|
+
// So the order has to exist before the shopper has typed anything. Demanding an
|
|
10
|
+
// email first is what forced the old "fill the form, then see the card fields"
|
|
11
|
+
// checkout — and it also meant every cart edit minted a NEW order, because the
|
|
12
|
+
// idempotency key moved with the cart.
|
|
13
|
+
//
|
|
14
|
+
// Served ONLY by the storefront runtime (a published website), like
|
|
15
|
+
// `startCheckout` itself. On a register it does not exist.
|
|
16
|
+
export {};
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
import { GetOutlets } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* A second location that CANNOT take an online payment.
|
|
4
|
+
*
|
|
5
|
+
* The shared demo data has exactly one outlet, so without this a pickup picker
|
|
6
|
+
* built against mocks would never render the case that matters: an outlet a
|
|
7
|
+
* shopper must not be allowed to check out against. `setOutlet` refuses this id,
|
|
8
|
+
* so the two mocks tell the same story.
|
|
9
|
+
*
|
|
10
|
+
* Mock-only, and deliberately NOT added to `MOCK_OUTLETS` — that array is shared
|
|
11
|
+
* with every other outlet mock and `MOCK_OUTLET`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MOCK_UNCONNECTED_OUTLET_ID = "outlet-mock-unconnected";
|
|
2
14
|
export declare const mockGetOutlets: GetOutlets;
|
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
import { MOCK_OUTLETS, safeSerialize } from "../../demo/database";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A second location that CANNOT take an online payment.
|
|
4
|
+
*
|
|
5
|
+
* The shared demo data has exactly one outlet, so without this a pickup picker
|
|
6
|
+
* built against mocks would never render the case that matters: an outlet a
|
|
7
|
+
* shopper must not be allowed to check out against. `setOutlet` refuses this id,
|
|
8
|
+
* so the two mocks tell the same story.
|
|
9
|
+
*
|
|
10
|
+
* Mock-only, and deliberately NOT added to `MOCK_OUTLETS` — that array is shared
|
|
11
|
+
* with every other outlet mock and `MOCK_OUTLET`.
|
|
12
|
+
*/
|
|
13
|
+
export const MOCK_UNCONNECTED_OUTLET_ID = "outlet-mock-unconnected";
|
|
14
|
+
const UNCONNECTED_OUTLET = {
|
|
15
|
+
_id: MOCK_UNCONNECTED_OUTLET_ID,
|
|
16
|
+
name: "Airport (not connected)",
|
|
17
|
+
address: "2 Terminal Road",
|
|
18
|
+
city: "San Francisco",
|
|
19
|
+
state: "CA",
|
|
20
|
+
country: "US",
|
|
21
|
+
postCode: "94128",
|
|
22
|
+
connected: false
|
|
23
|
+
};
|
|
24
|
+
export const mockGetOutlets = async (params) => {
|
|
25
|
+
console.log("[Mock] getOutlets called", params);
|
|
4
26
|
const outlets = safeSerialize(MOCK_OUTLETS).map((o) => ({
|
|
5
27
|
_id: o._id || o.id,
|
|
6
28
|
name: o.name || "",
|
|
7
29
|
address: o.address,
|
|
8
30
|
city: o.city,
|
|
9
31
|
state: o.state,
|
|
10
|
-
country: o.country
|
|
32
|
+
country: o.country,
|
|
33
|
+
connected: true
|
|
11
34
|
}));
|
|
35
|
+
const all = [...outlets, UNCONNECTED_OUTLET];
|
|
12
36
|
return {
|
|
13
|
-
outlets,
|
|
37
|
+
outlets: params?.connectedOnly ? all.filter((o) => o.connected) : all,
|
|
14
38
|
timestamp: new Date().toISOString()
|
|
15
39
|
};
|
|
16
40
|
};
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { CFOutletInfo } from "../../CommonTypes";
|
|
2
|
+
export interface GetOutletsParams {
|
|
3
|
+
/**
|
|
4
|
+
* Return only locations that can take an online payment. Storefront only.
|
|
5
|
+
* Default `false`, so a flow sees every outlet and decides how to present
|
|
6
|
+
* the ones it cannot sell from.
|
|
7
|
+
*/
|
|
8
|
+
connectedOnly?: boolean;
|
|
9
|
+
}
|
|
2
10
|
export interface GetOutletsResponse {
|
|
3
11
|
outlets: CFOutletInfo[];
|
|
4
12
|
timestamp: string;
|
|
5
13
|
}
|
|
6
|
-
|
|
14
|
+
/**
|
|
15
|
+
* List the company's outlets.
|
|
16
|
+
*
|
|
17
|
+
* ON A STOREFRONT this is the pickup picker's source. Each outlet carries
|
|
18
|
+
* `connected`, and an outlet with `connected: false` cannot take a payment, so
|
|
19
|
+
* `setOutlet` refuses it — filter those out rather than relying on the refusal.
|
|
20
|
+
*
|
|
21
|
+
* There are no coordinates on an outlet, so "closest branch" is not something
|
|
22
|
+
* this can answer; sort on the address fields or let the shopper choose.
|
|
23
|
+
*/
|
|
24
|
+
export type GetOutlets = (params?: GetOutletsParams) => Promise<GetOutletsResponse>;
|
|
@@ -15,5 +15,9 @@ import { GetRefundPlan } from './types';
|
|
|
15
15
|
* and no company cash-rounding setting, so it always describes a FULL refund
|
|
16
16
|
* with `rounding: 0` and no cash `payout`. Against real kaching the allocation
|
|
17
17
|
* tracks the live selection and carries the drawer snap.
|
|
18
|
+
*
|
|
19
|
+
* `rows` / `allocation.breakdown` follow the engine's reading of the order
|
|
20
|
+
* (`line.total` includes the line's tax) but prorate by plain quantity share;
|
|
21
|
+
* the engine's remainder carry across earlier partial refunds is not modelled.
|
|
18
22
|
*/
|
|
19
23
|
export declare const mockGetRefundPlan: GetRefundPlan;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MOCK_ORDERS } from '../../demo/database';
|
|
2
|
+
import { mockRefundBreakdown, mockRefundRows } from './mockRows';
|
|
2
3
|
/**
|
|
3
4
|
* Demo derivation of the runtime `getRefundPlan`. Builds the per-source rows
|
|
4
5
|
* from the mock order's `paymentMethods`, mirroring the runtime's capacity
|
|
@@ -15,6 +16,10 @@ import { MOCK_ORDERS } from '../../demo/database';
|
|
|
15
16
|
* and no company cash-rounding setting, so it always describes a FULL refund
|
|
16
17
|
* with `rounding: 0` and no cash `payout`. Against real kaching the allocation
|
|
17
18
|
* tracks the live selection and carries the drawer snap.
|
|
19
|
+
*
|
|
20
|
+
* `rows` / `allocation.breakdown` follow the engine's reading of the order
|
|
21
|
+
* (`line.total` includes the line's tax) but prorate by plain quantity share;
|
|
22
|
+
* the engine's remainder carry across earlier partial refunds is not modelled.
|
|
18
23
|
*/
|
|
19
24
|
export const mockGetRefundPlan = async (params) => {
|
|
20
25
|
console.log('[Mock] getRefundPlan called', params);
|
|
@@ -62,16 +67,19 @@ export const mockGetRefundPlan = async (params) => {
|
|
|
62
67
|
requiresGiftCardDestination: s.paymentType === 'redeem',
|
|
63
68
|
}));
|
|
64
69
|
const budget = legs.reduce((sum, l) => sum + l.amount, 0);
|
|
70
|
+
const rows = mockRefundRows(order);
|
|
65
71
|
return {
|
|
66
72
|
success: true,
|
|
67
73
|
orderId: order._id,
|
|
68
74
|
sources,
|
|
75
|
+
rows,
|
|
69
76
|
allocation: {
|
|
70
77
|
budget,
|
|
71
78
|
// No cash rounding in the demo, so the goods value and the budget agree.
|
|
72
79
|
itemTotal: budget,
|
|
73
80
|
rounding: 0,
|
|
74
81
|
legs,
|
|
82
|
+
breakdown: mockRefundBreakdown(rows, params?.items),
|
|
75
83
|
},
|
|
76
84
|
// Demo: no prior refunds, so remaining = captured minus the non-revenue load.
|
|
77
85
|
remainingRefundable: Math.max(0, totalCaptured - nonRefundableLiability),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CFActiveOrder } from '../../CommonTypes';
|
|
2
|
+
import type { GetRefundPlanParams, RefundPlanBreakdown, RefundPlanRow } from './types';
|
|
3
|
+
/** Every row of a demo order. The demo has no refund ledger, so everything is refundable. */
|
|
4
|
+
export declare function mockRefundRows(order: CFActiveOrder): RefundPlanRow[];
|
|
5
|
+
/** The selection's breakdown; no `items` means everything, like the demo's full-refund legs. */
|
|
6
|
+
export declare function mockRefundBreakdown(rows: RefundPlanRow[], items?: GetRefundPlanParams['items']): RefundPlanBreakdown;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Demo derivation of `getRefundPlan`'s `rows` and `allocation.breakdown`, for
|
|
2
|
+
// local/standalone mode only. See the note on `mockGetRefundPlan`.
|
|
3
|
+
const minor = (v) => Math.round(Number(v ?? 0)) || 0;
|
|
4
|
+
/** A line's whole-quantity money, reading `total` as tax-inclusive (the engine's reading). */
|
|
5
|
+
function lineAmounts(line) {
|
|
6
|
+
const tax = minor(line.totalTax);
|
|
7
|
+
const itemDiscount = (line.discount?.itemDiscounts ?? []).reduce((s, d) => s + minor(d.amount), 0);
|
|
8
|
+
const cartDiscount = minor(line.discount?.cartDiscount?.amount);
|
|
9
|
+
const net = Math.max(0, minor(line.total) - tax);
|
|
10
|
+
const taxes = (line.taxes ?? []).map((t) => ({
|
|
11
|
+
name: String(t.name ?? 'Tax'),
|
|
12
|
+
percentage: t.percentage,
|
|
13
|
+
amount: minor(t.amount),
|
|
14
|
+
}));
|
|
15
|
+
return { subtotal: net + itemDiscount + cartDiscount, itemDiscount, cartDiscount, tax, taxes, total: net + tax };
|
|
16
|
+
}
|
|
17
|
+
function scale(a, share) {
|
|
18
|
+
if (share >= 1)
|
|
19
|
+
return a;
|
|
20
|
+
const r = (n) => Math.round(n * share);
|
|
21
|
+
const out = {
|
|
22
|
+
subtotal: r(a.subtotal),
|
|
23
|
+
itemDiscount: r(a.itemDiscount),
|
|
24
|
+
cartDiscount: r(a.cartDiscount),
|
|
25
|
+
tax: r(a.tax),
|
|
26
|
+
taxes: a.taxes.map((t) => ({ ...t, amount: r(t.amount) })),
|
|
27
|
+
};
|
|
28
|
+
return { ...out, total: out.subtotal - out.itemDiscount - out.cartDiscount + out.tax };
|
|
29
|
+
}
|
|
30
|
+
/** Every row of a demo order. The demo has no refund ledger, so everything is refundable. */
|
|
31
|
+
export function mockRefundRows(order) {
|
|
32
|
+
const rows = [];
|
|
33
|
+
for (const li of order.lineItems ?? []) {
|
|
34
|
+
const itemKey = String(li.internalId || li.variantId || '');
|
|
35
|
+
if (!itemKey || !(li.quantity > 0))
|
|
36
|
+
continue;
|
|
37
|
+
rows.push({
|
|
38
|
+
type: 'product',
|
|
39
|
+
itemKey,
|
|
40
|
+
label: String(li.name ?? 'Item'),
|
|
41
|
+
...(li.sku ? { sku: li.sku } : {}),
|
|
42
|
+
...(li.attributes ? { attributes: li.attributes } : {}),
|
|
43
|
+
quantity: li.quantity,
|
|
44
|
+
refundableQuantity: li.quantity,
|
|
45
|
+
amounts: lineAmounts(li),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
for (const cs of order.customSales ?? []) {
|
|
49
|
+
if (!cs.customSaleId || !(cs.quantity > 0))
|
|
50
|
+
continue;
|
|
51
|
+
rows.push({
|
|
52
|
+
type: 'customSale',
|
|
53
|
+
itemKey: cs.customSaleId,
|
|
54
|
+
label: String(cs.name ?? 'Custom sale'),
|
|
55
|
+
quantity: cs.quantity,
|
|
56
|
+
refundableQuantity: cs.quantity,
|
|
57
|
+
amounts: lineAmounts(cs),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const fee of order.cartFees ?? []) {
|
|
61
|
+
const amount = minor(fee.amount);
|
|
62
|
+
const tax = minor(fee.tax);
|
|
63
|
+
rows.push({
|
|
64
|
+
type: 'fee',
|
|
65
|
+
itemKey: String(fee.id),
|
|
66
|
+
label: String(fee.label || 'Cart fee'),
|
|
67
|
+
quantity: 1,
|
|
68
|
+
refundableQuantity: 1,
|
|
69
|
+
amounts: { subtotal: amount, itemDiscount: 0, cartDiscount: 0, tax, taxes: [], total: amount + tax },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
for (const pm of order.paymentMethods ?? []) {
|
|
73
|
+
const tip = minor(pm.tip?.amount);
|
|
74
|
+
if (tip <= 0)
|
|
75
|
+
continue;
|
|
76
|
+
rows.push({
|
|
77
|
+
type: 'tip',
|
|
78
|
+
itemKey: pm.transactionId,
|
|
79
|
+
label: 'Tip',
|
|
80
|
+
paymentType: pm.paymentType,
|
|
81
|
+
quantity: 1,
|
|
82
|
+
refundableQuantity: 1,
|
|
83
|
+
amounts: { subtotal: tip, itemDiscount: 0, cartDiscount: 0, tax: 0, taxes: [], total: tip },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
/** The selection's breakdown; no `items` means everything, like the demo's full-refund legs. */
|
|
89
|
+
export function mockRefundBreakdown(rows, items) {
|
|
90
|
+
const picked = new Map((items ?? []).map((i) => [i.itemKey, i.quantity]));
|
|
91
|
+
const selected = rows
|
|
92
|
+
.map((row) => {
|
|
93
|
+
const quantity = items?.length
|
|
94
|
+
? Math.min(picked.get(row.itemKey) ?? 0, row.refundableQuantity)
|
|
95
|
+
: row.refundableQuantity;
|
|
96
|
+
return { type: row.type, itemKey: row.itemKey, quantity, amounts: scale(row.amounts, quantity / row.quantity) };
|
|
97
|
+
})
|
|
98
|
+
.filter((r) => r.quantity > 0);
|
|
99
|
+
const totals = { items: 0, discounts: 0, fees: 0, tax: 0, tip: 0, total: 0 };
|
|
100
|
+
for (const r of selected) {
|
|
101
|
+
const a = r.amounts;
|
|
102
|
+
if (r.type === 'fee')
|
|
103
|
+
totals.fees += a.subtotal;
|
|
104
|
+
else if (r.type === 'tip')
|
|
105
|
+
totals.tip += a.subtotal;
|
|
106
|
+
else {
|
|
107
|
+
totals.items += a.subtotal;
|
|
108
|
+
totals.discounts += a.itemDiscount + a.cartDiscount;
|
|
109
|
+
}
|
|
110
|
+
totals.tax += a.tax;
|
|
111
|
+
totals.total += a.total;
|
|
112
|
+
}
|
|
113
|
+
return { rows: selected, totals };
|
|
114
|
+
}
|
|
@@ -79,6 +79,81 @@ export interface RefundPlanLeg {
|
|
|
79
79
|
rounding: number;
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
export type RefundPlanRowType = 'product' | 'customSale' | 'fee' | 'tip';
|
|
83
|
+
/** One tax rate's share of a row's refund (minor units). */
|
|
84
|
+
export interface RefundPlanTaxLine {
|
|
85
|
+
name: string;
|
|
86
|
+
/** Decimal rate as stored on the order (e.g. `0.15`), when the order recorded one. */
|
|
87
|
+
percentage?: number;
|
|
88
|
+
amount: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The money a row refunds, split the way a receipt shows it. Every field is
|
|
92
|
+
* minor units and already rounded; `subtotal − itemDiscount − cartDiscount +
|
|
93
|
+
* tax === total` always holds. DISPLAY IT — never re-add or prorate it.
|
|
94
|
+
*/
|
|
95
|
+
export interface RefundPlanAmounts {
|
|
96
|
+
/** Before discounts, tax excluded. For a tip row, the tip itself. */
|
|
97
|
+
subtotal: number;
|
|
98
|
+
/** Per-item discounts on the refunded quantity (positive). */
|
|
99
|
+
itemDiscount: number;
|
|
100
|
+
/** Cart discount share on the refunded quantity (positive). */
|
|
101
|
+
cartDiscount: number;
|
|
102
|
+
/** Tax on the refunded quantity. Zero for a tip. */
|
|
103
|
+
tax: number;
|
|
104
|
+
/** `tax` per rate — the price breakdown's tax lines. */
|
|
105
|
+
taxes: RefundPlanTaxLine[];
|
|
106
|
+
/** What the row refunds. */
|
|
107
|
+
total: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* One refundable row of the order — a line item, custom sale, cart fee or tip —
|
|
111
|
+
* served ready to render. Rows with nothing left to refund are not listed.
|
|
112
|
+
*/
|
|
113
|
+
export interface RefundPlanRow {
|
|
114
|
+
type: RefundPlanRowType;
|
|
115
|
+
/** The key `items[].itemKey` takes, for this call and for `processPartialRefund`. */
|
|
116
|
+
itemKey: string;
|
|
117
|
+
/** Line or fee name; `Tip` for a tip. */
|
|
118
|
+
label: string;
|
|
119
|
+
/** Product lines only, when the order recorded them. */
|
|
120
|
+
sku?: string;
|
|
121
|
+
attributes?: string;
|
|
122
|
+
/** Tip rows only: the tender that took the tip (`card`, `cash`, …). */
|
|
123
|
+
paymentType?: string;
|
|
124
|
+
/** Quantity originally sold. Fees and tips are `1` — all or nothing. */
|
|
125
|
+
quantity: number;
|
|
126
|
+
/** Quantity still refundable — the stepper's max. */
|
|
127
|
+
refundableQuantity: number;
|
|
128
|
+
/** The money for refunding ALL of `refundableQuantity`. */
|
|
129
|
+
amounts: RefundPlanAmounts;
|
|
130
|
+
}
|
|
131
|
+
/** One selected row and what refunding the selected quantity of it moves. */
|
|
132
|
+
export interface RefundPlanSelectedRow {
|
|
133
|
+
type: RefundPlanRowType;
|
|
134
|
+
itemKey: string;
|
|
135
|
+
quantity: number;
|
|
136
|
+
amounts: RefundPlanAmounts;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The selection's goods value broken down for display. Minor units;
|
|
140
|
+
* `items − discounts + fees + tax + tip === total === allocation.itemTotal`.
|
|
141
|
+
*/
|
|
142
|
+
export interface RefundPlanTotals {
|
|
143
|
+
/** Σ selected line subtotals (before discounts, tax excluded). */
|
|
144
|
+
items: number;
|
|
145
|
+
/** Σ item + cart discounts on the selected lines (positive). */
|
|
146
|
+
discounts: number;
|
|
147
|
+
/** Σ selected cart fees, tax excluded. */
|
|
148
|
+
fees: number;
|
|
149
|
+
tax: number;
|
|
150
|
+
tip: number;
|
|
151
|
+
total: number;
|
|
152
|
+
}
|
|
153
|
+
export interface RefundPlanBreakdown {
|
|
154
|
+
rows: RefundPlanSelectedRow[];
|
|
155
|
+
totals: RefundPlanTotals;
|
|
156
|
+
}
|
|
82
157
|
/**
|
|
83
158
|
* The engine's own allocation of the CURRENT refund selection across the
|
|
84
159
|
* order's captures — what a flow renders and submits instead of computing a
|
|
@@ -106,11 +181,25 @@ export interface RefundPlanAllocation {
|
|
|
106
181
|
rounding: number;
|
|
107
182
|
/** One leg per source that receives money. Submit as `legs`, unchanged. */
|
|
108
183
|
legs: RefundPlanLeg[];
|
|
184
|
+
/**
|
|
185
|
+
* What the selection refunds, per row and in total — the numbers a refund
|
|
186
|
+
* dialog's rows and footer render. Optional so an older runtime still
|
|
187
|
+
* type-checks; kaching 1.12.1+ always sends it.
|
|
188
|
+
*/
|
|
189
|
+
breakdown?: RefundPlanBreakdown;
|
|
109
190
|
}
|
|
110
191
|
export interface GetRefundPlanResponse {
|
|
111
192
|
success: boolean;
|
|
112
193
|
orderId: string;
|
|
113
194
|
sources: RefundPlanSource[];
|
|
195
|
+
/**
|
|
196
|
+
* Every row still refundable on the order, with the money for refunding all
|
|
197
|
+
* of it — what a refund dialog lists before anything is selected. Replaces
|
|
198
|
+
* reading line totals off the order and deciding whether they include tax.
|
|
199
|
+
* Optional so an older runtime still type-checks; kaching 1.12.1+ always
|
|
200
|
+
* sends it.
|
|
201
|
+
*/
|
|
202
|
+
rows?: RefundPlanRow[];
|
|
114
203
|
/**
|
|
115
204
|
* Ready-to-submit allocation of the current selection. Present only when a
|
|
116
205
|
* refund selection exists on the active order. See {@link RefundPlanAllocation}.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume Checkout action
|
|
3
|
+
* Calls the resumeCheckout action on the parent window
|
|
4
|
+
*/
|
|
5
|
+
import { commandFrameClient } from '../../client';
|
|
6
|
+
export const resumeCheckout = async (params) => {
|
|
7
|
+
return await commandFrameClient.call('resumeCheckout', params ?? {});
|
|
8
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ResumeCheckout } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Standalone mock of the checkout return leg.
|
|
4
|
+
*
|
|
5
|
+
* It reads the SAME query parameters the real provider sends a shopper back
|
|
6
|
+
* with, so a builder can exercise the redirect path in preview by appending
|
|
7
|
+
* them by hand:
|
|
8
|
+
*
|
|
9
|
+
* ?sessionId=CS_MOCK&redirectResult=MOCK
|
|
10
|
+
*
|
|
11
|
+
* Without them it answers `{ resumed: false }` and does nothing, which is what
|
|
12
|
+
* the real command does on an ordinary page load. That is the branch a
|
|
13
|
+
* checkout screen hits on every normal visit, so it is the one worth getting
|
|
14
|
+
* right in a mock.
|
|
15
|
+
*/
|
|
16
|
+
export declare const mockResumeCheckout: ResumeCheckout;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mockPublishEvent } from '../../demo/database';
|
|
2
|
+
/**
|
|
3
|
+
* Standalone mock of the checkout return leg.
|
|
4
|
+
*
|
|
5
|
+
* It reads the SAME query parameters the real provider sends a shopper back
|
|
6
|
+
* with, so a builder can exercise the redirect path in preview by appending
|
|
7
|
+
* them by hand:
|
|
8
|
+
*
|
|
9
|
+
* ?sessionId=CS_MOCK&redirectResult=MOCK
|
|
10
|
+
*
|
|
11
|
+
* Without them it answers `{ resumed: false }` and does nothing, which is what
|
|
12
|
+
* the real command does on an ordinary page load. That is the branch a
|
|
13
|
+
* checkout screen hits on every normal visit, so it is the one worth getting
|
|
14
|
+
* right in a mock.
|
|
15
|
+
*/
|
|
16
|
+
export const mockResumeCheckout = async (params) => {
|
|
17
|
+
console.log('[Mock] resumeCheckout called', params);
|
|
18
|
+
const href = params?.url ?? (typeof window === 'undefined' ? '' : window.location.href);
|
|
19
|
+
let redirectResult = null;
|
|
20
|
+
try {
|
|
21
|
+
redirectResult = new URL(href).searchParams.get('redirectResult');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
redirectResult = null;
|
|
25
|
+
}
|
|
26
|
+
if (!redirectResult) {
|
|
27
|
+
return {
|
|
28
|
+
success: true,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
checkout: { resumed: false },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const suffix = String(Date.now()).slice(-6);
|
|
34
|
+
const checkout = {
|
|
35
|
+
resumed: true,
|
|
36
|
+
orderId: `mock-order-${suffix}`,
|
|
37
|
+
receiptId: `ON-001-${suffix}`,
|
|
38
|
+
// `Authorised` is what the real provider reports for a completed redirect.
|
|
39
|
+
// It is NOT settlement — see the checkout topic docs.
|
|
40
|
+
resultCode: 'Authorised',
|
|
41
|
+
sessionResult: `mock-session-result-${suffix}`,
|
|
42
|
+
orderPassword: `mock-pw-${suffix}`,
|
|
43
|
+
};
|
|
44
|
+
mockPublishEvent('checkout', 'payment-completed', {
|
|
45
|
+
orderId: checkout.orderId,
|
|
46
|
+
receiptId: checkout.receiptId,
|
|
47
|
+
resultCode: checkout.resultCode,
|
|
48
|
+
sessionResult: checkout.sessionResult,
|
|
49
|
+
});
|
|
50
|
+
return { success: true, timestamp: new Date().toISOString(), checkout };
|
|
51
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface ResumeCheckoutParams {
|
|
2
|
+
/**
|
|
3
|
+
* Where to read the provider's return data from. Defaults to the current
|
|
4
|
+
* page URL, which is where the shopper has just landed.
|
|
5
|
+
*
|
|
6
|
+
* Only override this if you moved the query string somewhere else before
|
|
7
|
+
* calling — for example if your router strips it on mount.
|
|
8
|
+
*/
|
|
9
|
+
url?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What the return leg produced. Every field past `resumed` is absent when
|
|
13
|
+
* `resumed` is `false`.
|
|
14
|
+
*/
|
|
15
|
+
export interface ResumeCheckoutResult {
|
|
16
|
+
/**
|
|
17
|
+
* `false` means THIS WAS AN ORDINARY PAGE LOAD — no provider return data was
|
|
18
|
+
* in the URL, so there was nothing to finish and nothing happened. It is not
|
|
19
|
+
* an error, and it is the answer on every normal visit to the page. Call this
|
|
20
|
+
* unconditionally when your checkout or confirmation screen mounts and branch
|
|
21
|
+
* on this field.
|
|
22
|
+
*/
|
|
23
|
+
resumed: boolean;
|
|
24
|
+
orderId?: string;
|
|
25
|
+
receiptId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The provider's result code for the completed attempt, e.g. `Authorised` or
|
|
28
|
+
* `Refused`. As everywhere else in this topic, `Authorised` is an
|
|
29
|
+
* authorisation and NOT a settlement.
|
|
30
|
+
*/
|
|
31
|
+
resultCode?: string;
|
|
32
|
+
/** Opaque proof of the payment — see `PaymentCompletedPayload.sessionResult`. */
|
|
33
|
+
sessionResult?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The one-time order password from the original checkout, recovered from
|
|
36
|
+
* browser storage. The page that held it in memory is gone, so this is the
|
|
37
|
+
* only way back to the order's status after a redirect.
|
|
38
|
+
*/
|
|
39
|
+
orderPassword?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ResumeCheckoutResponse {
|
|
42
|
+
success: boolean;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
checkout: ResumeCheckoutResult;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Finish a checkout the shopper was redirected away from.
|
|
48
|
+
*
|
|
49
|
+
* SAFE TO CALL ON EVERY PAGE LOAD, and that is how it is meant to be used: on
|
|
50
|
+
* an ordinary visit it finds no return data and answers `{ resumed: false }`
|
|
51
|
+
* without touching the network. Calling it twice for one return is also safe —
|
|
52
|
+
* the return data is consumed the first time.
|
|
53
|
+
*
|
|
54
|
+
* The outcome is ALSO published on the `checkout` topic (`payment-completed` or
|
|
55
|
+
* `payment-failed`), exactly as it would have been had the shopper never left,
|
|
56
|
+
* so a page that already subscribes needs no second code path. Subscribe first,
|
|
57
|
+
* then call this, or the event fires before you are listening.
|
|
58
|
+
*/
|
|
59
|
+
export type ResumeCheckout = (params?: ResumeCheckoutParams) => Promise<ResumeCheckoutResponse>;
|