@tallyui/pos 3.2.0 → 3.2.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/index.d.ts +87 -1
- package/dist/index.js +193 -11
- package/package.json +6 -6
- package/src/index.ts +4 -0
- package/src/order/index.ts +4 -0
- package/src/order/order-builder.ts +76 -6
- package/src/order/order-drafts.ts +5 -0
- package/src/order/types.ts +23 -1
- package/src/pos-order/finalize.ts +3 -0
- package/src/receipt/build-receipt-data.ts +6 -1
- package/src/receipt/types.ts +2 -0
- package/src/sale/use-sale.ts +50 -6
package/dist/index.d.ts
CHANGED
|
@@ -245,6 +245,8 @@ interface Order {
|
|
|
245
245
|
id: string;
|
|
246
246
|
status: 'draft' | 'parked' | 'saved' | 'completed';
|
|
247
247
|
lineItems: LineItem[];
|
|
248
|
+
fees?: FeeLine[];
|
|
249
|
+
shipping?: ShippingLine[];
|
|
248
250
|
discounts: AppliedDiscount[];
|
|
249
251
|
payments: Payment[];
|
|
250
252
|
customer: CustomerSummary | null;
|
|
@@ -281,6 +283,16 @@ interface DisplayTotals {
|
|
|
281
283
|
taxMinor: number;
|
|
282
284
|
totalMinor: number;
|
|
283
285
|
lines: DisplayLine[];
|
|
286
|
+
fees?: Array<{
|
|
287
|
+
id: string;
|
|
288
|
+
name: string;
|
|
289
|
+
amountMinor: number;
|
|
290
|
+
}>;
|
|
291
|
+
shipping?: Array<{
|
|
292
|
+
id: string;
|
|
293
|
+
name: string;
|
|
294
|
+
amountMinor: number;
|
|
295
|
+
}>;
|
|
284
296
|
orderDiscountMinor: number;
|
|
285
297
|
}
|
|
286
298
|
/** One line in the display mode (ADR-063): before any discount, with its own discounts as sub-rows. */
|
|
@@ -295,6 +307,8 @@ interface DisplayLine {
|
|
|
295
307
|
}
|
|
296
308
|
interface LineItem {
|
|
297
309
|
id: string;
|
|
310
|
+
custom?: true;
|
|
311
|
+
taxStatus?: 'none';
|
|
298
312
|
productId: string;
|
|
299
313
|
variantId?: string;
|
|
300
314
|
name: string;
|
|
@@ -312,6 +326,27 @@ interface LineItem {
|
|
|
312
326
|
/** Set only when the price's tax mode differs from the order's; named price mode → order mode. */
|
|
313
327
|
priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
|
|
314
328
|
}
|
|
329
|
+
interface ChargeLine {
|
|
330
|
+
id: string;
|
|
331
|
+
name: string;
|
|
332
|
+
amountMinor: number;
|
|
333
|
+
taxClass?: string;
|
|
334
|
+
taxStatus: 'taxable' | 'none';
|
|
335
|
+
taxLines: LineItem['taxLines'];
|
|
336
|
+
netMinor: number;
|
|
337
|
+
taxMicros: number;
|
|
338
|
+
}
|
|
339
|
+
interface FeeLine extends ChargeLine {
|
|
340
|
+
}
|
|
341
|
+
interface ShippingLine extends ChargeLine {
|
|
342
|
+
methodId?: string;
|
|
343
|
+
}
|
|
344
|
+
interface ChargeInput {
|
|
345
|
+
name: string;
|
|
346
|
+
amountMinor: number;
|
|
347
|
+
taxClass?: string;
|
|
348
|
+
taxStatus?: 'taxable' | 'none';
|
|
349
|
+
}
|
|
315
350
|
interface LineTaxLine {
|
|
316
351
|
code?: string;
|
|
317
352
|
ratePpm: number;
|
|
@@ -348,6 +383,8 @@ interface PaymentMethod {
|
|
|
348
383
|
}
|
|
349
384
|
interface AddLineInput {
|
|
350
385
|
productId: string;
|
|
386
|
+
custom?: true;
|
|
387
|
+
taxStatus?: 'taxable' | 'none';
|
|
351
388
|
variantId?: string;
|
|
352
389
|
name: string;
|
|
353
390
|
sku?: string;
|
|
@@ -375,6 +412,16 @@ interface OrderBuilder {
|
|
|
375
412
|
quantity?: number;
|
|
376
413
|
}): string;
|
|
377
414
|
addLine(input: AddLineInput): string;
|
|
415
|
+
addFee(input: ChargeInput): string;
|
|
416
|
+
updateFee(id: string, patch: Partial<ChargeInput>): void;
|
|
417
|
+
removeFee(id: string): void;
|
|
418
|
+
addShipping(input: ChargeInput & {
|
|
419
|
+
methodId?: string;
|
|
420
|
+
}): string;
|
|
421
|
+
updateShipping(id: string, patch: Partial<ChargeInput & {
|
|
422
|
+
methodId?: string;
|
|
423
|
+
}>): void;
|
|
424
|
+
removeShipping(id: string): void;
|
|
378
425
|
updateQuantity(lineId: string, quantity: number): void;
|
|
379
426
|
/** Sets a line's unit price (a till price edit), in minor units of the order's currency and the line's own tax mode; its discounts and tax are recomputed. */
|
|
380
427
|
setUnitPrice(lineId: string, amountMinor: number): void;
|
|
@@ -496,6 +543,14 @@ interface ReceiptData {
|
|
|
496
543
|
register?: string;
|
|
497
544
|
};
|
|
498
545
|
lineItems: ReceiptLineItem[];
|
|
546
|
+
fees: Array<{
|
|
547
|
+
name: string;
|
|
548
|
+
amountMinor: number;
|
|
549
|
+
}>;
|
|
550
|
+
shipping: Array<{
|
|
551
|
+
name: string;
|
|
552
|
+
amountMinor: number;
|
|
553
|
+
}>;
|
|
499
554
|
discounts: {
|
|
500
555
|
label: string;
|
|
501
556
|
amountMinor: number;
|
|
@@ -1886,6 +1941,37 @@ declare function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
1886
1941
|
canContinue: boolean;
|
|
1887
1942
|
add(entry: CatalogueEntry<any>, traits: ProductTraits<any>): void;
|
|
1888
1943
|
setQuantity(lineId: string, quantity: number): void;
|
|
1944
|
+
/** ADR-075: adds a fee; returns its id or the builder's refusal. */
|
|
1945
|
+
addFee(input: ChargeInput): {
|
|
1946
|
+
id: string;
|
|
1947
|
+
} | string;
|
|
1948
|
+
/** ADR-075: updates a fee; returns a refusal or null. */
|
|
1949
|
+
updateFee(id: string, patch: Partial<ChargeInput>): string | null;
|
|
1950
|
+
/** ADR-075: removes a fee; returns a refusal or null. */
|
|
1951
|
+
removeFee(id: string): string | null;
|
|
1952
|
+
/** ADR-075: adds shipping; returns its id or the builder's refusal. */
|
|
1953
|
+
addShipping(input: ChargeInput & {
|
|
1954
|
+
methodId?: string;
|
|
1955
|
+
}): {
|
|
1956
|
+
id: string;
|
|
1957
|
+
} | string;
|
|
1958
|
+
/** ADR-075: updates shipping; returns a refusal or null. */
|
|
1959
|
+
updateShipping(id: string, patch: Partial<ChargeInput & {
|
|
1960
|
+
methodId?: string;
|
|
1961
|
+
}>): string | null;
|
|
1962
|
+
/** ADR-075: removes shipping; returns a refusal or null. */
|
|
1963
|
+
removeShipping(id: string): string | null;
|
|
1964
|
+
/** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
|
|
1965
|
+
addCustomLine(input: {
|
|
1966
|
+
name: string;
|
|
1967
|
+
priceMinor: number;
|
|
1968
|
+
quantity?: number;
|
|
1969
|
+
taxClass?: string;
|
|
1970
|
+
taxStatus?: "taxable" | "none";
|
|
1971
|
+
sku?: string;
|
|
1972
|
+
}): {
|
|
1973
|
+
id: string;
|
|
1974
|
+
} | string;
|
|
1889
1975
|
/** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
|
|
1890
1976
|
setUnitPrice(lineId: string, amountMinor: number): string | null;
|
|
1891
1977
|
remove(lineId: string): void;
|
|
@@ -2329,4 +2415,4 @@ declare function planLegs(plan: TenderPlan, rowsSinceFrom: {
|
|
|
2329
2415
|
};
|
|
2330
2416
|
};
|
|
2331
2417
|
|
|
2332
|
-
export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OutboxState, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, initTenderState, initialTenderState, isServerDecimal, labelKeys, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, requireOpenSession, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
|
|
2418
|
+
export { type Actor, type AddLineInput, type AppliedDiscount, type BackendNotFound, type BasketLine, CartError, type CashMovement, type CashMovementCollection, type CatalogueEntry, type ChargeInput, type ChargeLine, type Closure, type ClosureCollection, type ClosureContext, type ClosureScope, type CommandTransport, type Correction, CurrencyProvider, type CurrencyProviderProps, type CustomerSummary, DISCOUNTS_UNSUPPORTED, type Discount, type DisplayLine, type DisplayTotals, type FeeLine, type FinalizeOptions, type HttpTransportOptions, type LedgerRow, type LineItem, type LineTaxLine, type LogEntry, type LogLevel, type LogSink, type Logger, MAX_TENDER_MINOR, MICROS_PER_MINOR, type Movement$1 as Movement, type MovementType, type Order, type OrderBuilder, type OrderBuilderOptions, OrderContentMismatchError, type OrderManager, type OrderManagerOptions, type OrderOutbox, type OrderOutboxOptions, type OrderTaxTotals, type OutboxState, type ParkedOrderSummary, type Payment, type PaymentMethod, type PaymentTransport, type PlanLeg, type PosOrder, type PosOrderLine, type PosOrderLocalWarning, PosOrderOpenClosedError, type PosOrderPayment, type PosOrderServerFailures, type PosOrderSyncStatus, type RateTaxLine, type ReceiptConfig, type ReceiptData, type ReceiptLineItem, type RecordedFigures, RegisterApprovalRequiredError, type RegisterBucket, RegisterCloseIncompleteError, type RegisterCommand, type RegisterCommandCollection, type RegisterCounters, type RegisterDocument, type RegisterFact, type RegisterHost, RegisterIdInvalidError, RegisterMovementAmountError, RegisterMovementReasonError, RegisterMovementStrandedError, type RegisterOutbox, type RegisterOutboxOptions, type RegisterSession, RegisterSessionAlreadyOpenError, RegisterSessionClosedError, type RegisterSessionCollection, RegisterSessionRequiredError, type RegisterStore, RegisterTenderInProgressError, type Repository, type ResolveStoreSettingsOptions, SALE_SAVING, type SaleStage, type SentOrder, type ShippingLine, type SplitTab, type StoreSettingsResolution, type StoreSettingsState, TAX_ROUNDING_MIXED_NOTE, type TaxContext, type TaxLineInput, TaxProvider, type TaxProviderProps, type TaxRateMap, type TenderAction, type TenderKey, type TenderLineId, type TenderPlan, type TenderState, type TenderView, type TransportOutcome, UnsupportedOrderVersionError, type UseOrderOutboxOptions, type UseOrderOutboxResult, type UseRegisterOutboxOptions, type UseRegisterOutboxResult, type UseRegisterSessionOptions, activePlan, addEntryToCart, addPosOrderCollection, advancePerpetual, allocateOrderDiscount, appliedMinor, backToSelling, bindRegister, buildClosureDocument, buildReceiptData, buildXReportDocument, callbackSink, cashMovementSchema, catalogueEntries, changeMinor, clampClosureScope, closeNeedsApproval, closeSession, closureCommand, closureSchema, computeOrderTax, consoleSink, countVariance, createBackendNotFound, createHttpCommandTransport, createLogger, createOrderBuilder, createOrderManager, createOrderOutbox, createRegisterOutbox, createRepository, denominationTotal, denominations, deriveExpected, deriveSettled, ensureRegister, evenSplitShareMinor, exportCsv, finalizeOrder, findEntryByCode, formatClosureDate, getBoundRegisterId, getDeviceId, initTenderState, initialTenderState, isServerDecimal, labelKeys, minorToDecimal, mintClosureNumber, mintUuid, movementCommand, movementFieldError, needsAttention, nextSaleCounter, normalizeAmount, observeRegister$, openSession, openSessionSelector, orderDraftSchema, outboxLogger, overThreshold, parkedOrderSummaries$, parseMinor, planLegs, posOrderCollection, posOrderSchema, posOrdersLogger, quickTenderedAmounts, ratePpmFromPercent, readBoundRegister, readRegister, reconcileRegisterCommands, recordMovement, recordRegisterFact, registerCommandCollection, registerCommandSchema, registerCommandsLogger, registerFactsLogger, registerSessionCollection, registerSessionSchema, requireOpenSession, resolveStoreSettings, restoreOrderDraft, roundMicrosToMinor, saleLogger, sameSale, searchProducts, selectClosureRows, sessionOpenCommand, sessionTransitionCommand, stampSession, startCounting, stockOverlay$, stockOverlayAsOf$, taxFiguresForBasket, taxLinesByRate, taxLogger, taxMicros, taxProviderProps, tenderReducer, toOrderCreateEnvelope, unbindRegister, useCurrencyCode, useCurrencyFormatter, useOrderOutbox, useParkedSales, useRegisterOutbox, useRegisterSession, useSale, useStoreSettings, useTax, uuidv7, validAmount, varianceText, variantPriceLabel, voidMovement, withPricingContext, writeClosure, writeOrderDraft };
|
package/dist/index.js
CHANGED
|
@@ -523,6 +523,8 @@ function createOrderBuilder(options) {
|
|
|
523
523
|
const currency = options.currency.toUpperCase();
|
|
524
524
|
const orderId = options.id ?? uid();
|
|
525
525
|
let lineItems = [];
|
|
526
|
+
let fees = [];
|
|
527
|
+
let shipping = [];
|
|
526
528
|
let orderDiscounts = [];
|
|
527
529
|
let payments = [];
|
|
528
530
|
let customer = null;
|
|
@@ -576,8 +578,15 @@ function createOrderBuilder(options) {
|
|
|
576
578
|
});
|
|
577
579
|
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
578
580
|
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
579
|
-
const { subtotalMinor
|
|
580
|
-
|
|
581
|
+
const { subtotalMinor } = sumLines(lines, taxContext.rounding);
|
|
582
|
+
const charges = [...fees, ...shipping];
|
|
583
|
+
const taxedLines = [...lines, ...charges.map((charge) => ({
|
|
584
|
+
...charge,
|
|
585
|
+
taxInclusive: taxContext.pricesIncludeTax,
|
|
586
|
+
taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString()
|
|
587
|
+
}))];
|
|
588
|
+
const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
|
|
589
|
+
if (taxContext.rounding?.granularity === "per_rate_group_items" && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
|
|
581
590
|
perRateGroupWarned.add(taxContext);
|
|
582
591
|
taxLogger.warn("per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)");
|
|
583
592
|
}
|
|
@@ -601,7 +610,7 @@ function createOrderBuilder(options) {
|
|
|
601
610
|
(sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum),
|
|
602
611
|
orderDiscountDisplay
|
|
603
612
|
);
|
|
604
|
-
const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount;
|
|
613
|
+
const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount - charges.reduce((sum, charge) => sum + charge.amountMinor, 0);
|
|
605
614
|
const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
|
|
606
615
|
const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
|
|
607
616
|
const conversions = converted.reduce((count, index) => count + [
|
|
@@ -635,7 +644,9 @@ function createOrderBuilder(options) {
|
|
|
635
644
|
taxMinor,
|
|
636
645
|
totalMinor,
|
|
637
646
|
lines: displayLines,
|
|
638
|
-
orderDiscountMinor: orderDiscountDisplay
|
|
647
|
+
orderDiscountMinor: orderDiscountDisplay,
|
|
648
|
+
...fees.length ? { fees: fees.map(({ id: id2, name, amountMinor }) => ({ id: id2, name, amountMinor })) } : {},
|
|
649
|
+
...shipping.length ? { shipping: shipping.map(({ id: id2, name, amountMinor }) => ({ id: id2, name, amountMinor })) } : {}
|
|
639
650
|
};
|
|
640
651
|
const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
|
|
641
652
|
const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
|
|
@@ -644,6 +655,8 @@ function createOrderBuilder(options) {
|
|
|
644
655
|
id: orderId,
|
|
645
656
|
status: "draft",
|
|
646
657
|
lineItems: lines,
|
|
658
|
+
...fees.length ? { fees: [...fees] } : {},
|
|
659
|
+
...shipping.length ? { shipping: [...shipping] } : {},
|
|
647
660
|
discounts: recalcedOrderDiscounts,
|
|
648
661
|
payments: [...payments],
|
|
649
662
|
customer,
|
|
@@ -666,6 +679,42 @@ function createOrderBuilder(options) {
|
|
|
666
679
|
function emit() {
|
|
667
680
|
subject.next(buildOrder());
|
|
668
681
|
}
|
|
682
|
+
function recalculateCharge(id2, input) {
|
|
683
|
+
if (!Number.isInteger(input.amountMinor)) throw new RangeError("Amount must be integer minor units");
|
|
684
|
+
if (input.amountMinor < 0) throw new RangeError("Amount must be >= 0");
|
|
685
|
+
if (!input.name.trim()) throw new RangeError("Name must not be empty");
|
|
686
|
+
const taxStatus = input.taxStatus ?? "taxable";
|
|
687
|
+
const code = taxContext.getTaxRateCode?.(input.taxClass);
|
|
688
|
+
const line = recalculateLine({
|
|
689
|
+
id: id2,
|
|
690
|
+
productId: id2,
|
|
691
|
+
name: input.name,
|
|
692
|
+
sku: "",
|
|
693
|
+
unitPriceMinor: input.amountMinor,
|
|
694
|
+
quantity: 1,
|
|
695
|
+
taxLines: [{
|
|
696
|
+
...code !== void 0 ? { code } : {},
|
|
697
|
+
ratePpm: taxStatus === "none" ? 0 : taxContext.getTaxRatePpm(input.taxClass),
|
|
698
|
+
taxMicros: "0"
|
|
699
|
+
}],
|
|
700
|
+
discounts: [],
|
|
701
|
+
discountMinor: 0,
|
|
702
|
+
orderDiscountMinor: 0,
|
|
703
|
+
netMinor: 0,
|
|
704
|
+
taxMicros: "0",
|
|
705
|
+
taxInclusive: taxContext.pricesIncludeTax
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
id: id2,
|
|
709
|
+
name: input.name,
|
|
710
|
+
amountMinor: input.amountMinor,
|
|
711
|
+
taxClass: input.taxClass,
|
|
712
|
+
taxStatus,
|
|
713
|
+
taxLines: line.taxLines,
|
|
714
|
+
netMinor: line.netMinor,
|
|
715
|
+
taxMicros: Number(line.taxMicros)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
669
718
|
function addLine(input) {
|
|
670
719
|
const { productId, variantId, unitPrice } = input;
|
|
671
720
|
const quantity = input.quantity ?? 1;
|
|
@@ -673,10 +722,10 @@ function createOrderBuilder(options) {
|
|
|
673
722
|
if (!Number.isInteger(unitPrice.amount)) throw new RangeError("Price must be integer minor units");
|
|
674
723
|
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError("Quantity must be an integer >= 1");
|
|
675
724
|
const code = input.taxRates ? void 0 : taxContext.getTaxRateCode?.(input.taxClass);
|
|
676
|
-
const taxRates = input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
725
|
+
const taxRates = input.taxStatus === "none" ? [{ ...code !== void 0 ? { code } : {}, ratePpm: 0 }] : input.taxRates ?? [{ ...code !== void 0 ? { code } : {}, ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
677
726
|
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
678
727
|
const existing = lineItems.find(
|
|
679
|
-
(li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
|
|
728
|
+
(li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive && li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length && li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm)
|
|
680
729
|
);
|
|
681
730
|
if (existing) {
|
|
682
731
|
lineItems = lineItems.map(
|
|
@@ -689,6 +738,8 @@ function createOrderBuilder(options) {
|
|
|
689
738
|
const line = {
|
|
690
739
|
id: lineId,
|
|
691
740
|
productId,
|
|
741
|
+
...input.custom ? { custom: true } : {},
|
|
742
|
+
...input.taxStatus === "none" ? { taxStatus: "none" } : {},
|
|
692
743
|
variantId,
|
|
693
744
|
name: input.name,
|
|
694
745
|
sku: input.sku ?? "",
|
|
@@ -716,6 +767,43 @@ function createOrderBuilder(options) {
|
|
|
716
767
|
return {
|
|
717
768
|
order$: subject.asObservable(),
|
|
718
769
|
addLine,
|
|
770
|
+
addFee(input) {
|
|
771
|
+
const fee = recalculateCharge(uid(), input);
|
|
772
|
+
fees = [...fees, fee];
|
|
773
|
+
emit();
|
|
774
|
+
return fee.id;
|
|
775
|
+
},
|
|
776
|
+
updateFee(id2, patch) {
|
|
777
|
+
const fee = fees.find((entry) => entry.id === id2);
|
|
778
|
+
if (!fee) throw new Error(`Unknown fee ${id2}`);
|
|
779
|
+
const updated = recalculateCharge(id2, { ...fee, ...patch });
|
|
780
|
+
fees = fees.map((entry) => entry.id === id2 ? updated : entry);
|
|
781
|
+
emit();
|
|
782
|
+
},
|
|
783
|
+
removeFee(id2) {
|
|
784
|
+
if (!fees.some((entry) => entry.id === id2)) throw new Error(`Unknown fee ${id2}`);
|
|
785
|
+
fees = fees.filter((entry) => entry.id !== id2);
|
|
786
|
+
emit();
|
|
787
|
+
},
|
|
788
|
+
addShipping(input) {
|
|
789
|
+
const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
|
|
790
|
+
shipping = [...shipping, charge];
|
|
791
|
+
emit();
|
|
792
|
+
return charge.id;
|
|
793
|
+
},
|
|
794
|
+
updateShipping(id2, patch) {
|
|
795
|
+
const charge = shipping.find((entry) => entry.id === id2);
|
|
796
|
+
if (!charge) throw new Error(`Unknown shipping ${id2}`);
|
|
797
|
+
const input = { ...charge, ...patch };
|
|
798
|
+
const updated = { ...recalculateCharge(id2, input), methodId: input.methodId };
|
|
799
|
+
shipping = shipping.map((entry) => entry.id === id2 ? updated : entry);
|
|
800
|
+
emit();
|
|
801
|
+
},
|
|
802
|
+
removeShipping(id2) {
|
|
803
|
+
if (!shipping.some((entry) => entry.id === id2)) throw new Error(`Unknown shipping ${id2}`);
|
|
804
|
+
shipping = shipping.filter((entry) => entry.id !== id2);
|
|
805
|
+
emit();
|
|
806
|
+
},
|
|
719
807
|
addProduct(doc, traits, opts) {
|
|
720
808
|
const productId = traits.getId(doc);
|
|
721
809
|
if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
|
|
@@ -811,6 +899,8 @@ function createOrderBuilder(options) {
|
|
|
811
899
|
},
|
|
812
900
|
clear() {
|
|
813
901
|
lineItems = [];
|
|
902
|
+
fees = [];
|
|
903
|
+
shipping = [];
|
|
814
904
|
orderDiscounts = [];
|
|
815
905
|
payments = [];
|
|
816
906
|
customer = null;
|
|
@@ -988,6 +1078,8 @@ function restoreOrderDraft(saved, options) {
|
|
|
988
1078
|
for (const line of saved.lineItems) {
|
|
989
1079
|
const lineId = builder.addLine({
|
|
990
1080
|
productId: line.productId,
|
|
1081
|
+
custom: line.custom,
|
|
1082
|
+
taxStatus: line.taxStatus,
|
|
991
1083
|
variantId: line.variantId,
|
|
992
1084
|
name: line.name,
|
|
993
1085
|
sku: line.sku,
|
|
@@ -1005,6 +1097,8 @@ function restoreOrderDraft(saved, options) {
|
|
|
1005
1097
|
});
|
|
1006
1098
|
}
|
|
1007
1099
|
}
|
|
1100
|
+
for (const fee of saved.fees ?? []) builder.addFee(fee);
|
|
1101
|
+
for (const charge of saved.shipping ?? []) builder.addShipping(charge);
|
|
1008
1102
|
for (const discount of saved.discounts) {
|
|
1009
1103
|
builder.applyOrderDiscount({
|
|
1010
1104
|
type: discount.type,
|
|
@@ -1105,7 +1199,11 @@ function useParkedSales(drafts) {
|
|
|
1105
1199
|
|
|
1106
1200
|
// src/receipt/build-receipt-data.ts
|
|
1107
1201
|
function buildReceiptData(order, config) {
|
|
1108
|
-
const
|
|
1202
|
+
const taxedLines = [...order.lineItems, ...[...order.fees ?? [], ...order.shipping ?? []].map((charge) => ({
|
|
1203
|
+
...charge,
|
|
1204
|
+
taxInclusive: order.pricesIncludeTax
|
|
1205
|
+
}))];
|
|
1206
|
+
const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
|
|
1109
1207
|
({ label: label2, code, ratePpm, amountMinor }) => ({ label: label2, code, ratePpm, amountMinor })
|
|
1110
1208
|
);
|
|
1111
1209
|
let taxSoFar = order.lineItems.reduce(
|
|
@@ -1145,6 +1243,8 @@ function buildReceiptData(order, config) {
|
|
|
1145
1243
|
label: label(d),
|
|
1146
1244
|
amountMinor: d.amountMinor
|
|
1147
1245
|
})),
|
|
1246
|
+
fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
|
|
1247
|
+
shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
|
|
1148
1248
|
orderDiscountMinor: order.display.orderDiscountMinor,
|
|
1149
1249
|
totals: {
|
|
1150
1250
|
taxInclusive: order.display.taxInclusive,
|
|
@@ -1321,6 +1421,9 @@ function referenceReason(value) {
|
|
|
1321
1421
|
return value.includes("\0") ? "nul" : null;
|
|
1322
1422
|
}
|
|
1323
1423
|
function finalizeOrder(order, options = {}) {
|
|
1424
|
+
if (order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) {
|
|
1425
|
+
throw new Error("finalize: fees, shipping and custom lines need order.create version 5, which this release does not send yet");
|
|
1426
|
+
}
|
|
1324
1427
|
if (!order.lineItems.length) throw new Error("finalize: no lines");
|
|
1325
1428
|
if (order.discountMinor < 0 || order.lineItems.some((line) => line.discountMinor < 0) || order.discounts.some((d) => d.amountMinor < 0)) {
|
|
1326
1429
|
throw new Error("finalize: negative discount");
|
|
@@ -3628,10 +3731,10 @@ function useSale(settings, opts) {
|
|
|
3628
3731
|
const subscription = rendered.order$.subscribe(setOrder);
|
|
3629
3732
|
return () => subscription.unsubscribe();
|
|
3630
3733
|
}, [rendered]);
|
|
3631
|
-
const idle3 = stage.kind === "cart" && !order.lineItems.length;
|
|
3734
|
+
const idle3 = stage.kind === "cart" && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
|
|
3632
3735
|
useLayoutEffect(() => {
|
|
3633
3736
|
const live = builderNow.current.getSnapshot();
|
|
3634
|
-
const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !inFlight.current && !pending.current;
|
|
3737
|
+
const liveIdle = stageNow.current.kind === "cart" && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
|
|
3635
3738
|
if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
|
|
3636
3739
|
});
|
|
3637
3740
|
useEffect4(() => () => {
|
|
@@ -3754,6 +3857,85 @@ function useSale(settings, opts) {
|
|
|
3754
3857
|
setQuantity(lineId, quantity) {
|
|
3755
3858
|
if (!locked()) builderNow.current.updateQuantity(lineId, quantity);
|
|
3756
3859
|
},
|
|
3860
|
+
/** ADR-075: adds a fee; returns its id or the builder's refusal. */
|
|
3861
|
+
addFee(input) {
|
|
3862
|
+
if (locked()) return SALE_SAVING;
|
|
3863
|
+
try {
|
|
3864
|
+
return { id: builderNow.current.addFee(input) };
|
|
3865
|
+
} catch (error2) {
|
|
3866
|
+
if (error2 instanceof Error) return error2.message;
|
|
3867
|
+
throw error2;
|
|
3868
|
+
}
|
|
3869
|
+
},
|
|
3870
|
+
/** ADR-075: updates a fee; returns a refusal or null. */
|
|
3871
|
+
updateFee(id2, patch) {
|
|
3872
|
+
if (locked()) return SALE_SAVING;
|
|
3873
|
+
try {
|
|
3874
|
+
builderNow.current.updateFee(id2, patch);
|
|
3875
|
+
return null;
|
|
3876
|
+
} catch (error2) {
|
|
3877
|
+
if (error2 instanceof Error) return error2.message;
|
|
3878
|
+
throw error2;
|
|
3879
|
+
}
|
|
3880
|
+
},
|
|
3881
|
+
/** ADR-075: removes a fee; returns a refusal or null. */
|
|
3882
|
+
removeFee(id2) {
|
|
3883
|
+
if (locked()) return SALE_SAVING;
|
|
3884
|
+
try {
|
|
3885
|
+
builderNow.current.removeFee(id2);
|
|
3886
|
+
return null;
|
|
3887
|
+
} catch (error2) {
|
|
3888
|
+
if (error2 instanceof Error) return error2.message;
|
|
3889
|
+
throw error2;
|
|
3890
|
+
}
|
|
3891
|
+
},
|
|
3892
|
+
/** ADR-075: adds shipping; returns its id or the builder's refusal. */
|
|
3893
|
+
addShipping(input) {
|
|
3894
|
+
if (locked()) return SALE_SAVING;
|
|
3895
|
+
try {
|
|
3896
|
+
return { id: builderNow.current.addShipping(input) };
|
|
3897
|
+
} catch (error2) {
|
|
3898
|
+
if (error2 instanceof Error) return error2.message;
|
|
3899
|
+
throw error2;
|
|
3900
|
+
}
|
|
3901
|
+
},
|
|
3902
|
+
/** ADR-075: updates shipping; returns a refusal or null. */
|
|
3903
|
+
updateShipping(id2, patch) {
|
|
3904
|
+
if (locked()) return SALE_SAVING;
|
|
3905
|
+
try {
|
|
3906
|
+
builderNow.current.updateShipping(id2, patch);
|
|
3907
|
+
return null;
|
|
3908
|
+
} catch (error2) {
|
|
3909
|
+
if (error2 instanceof Error) return error2.message;
|
|
3910
|
+
throw error2;
|
|
3911
|
+
}
|
|
3912
|
+
},
|
|
3913
|
+
/** ADR-075: removes shipping; returns a refusal or null. */
|
|
3914
|
+
removeShipping(id2) {
|
|
3915
|
+
if (locked()) return SALE_SAVING;
|
|
3916
|
+
try {
|
|
3917
|
+
builderNow.current.removeShipping(id2);
|
|
3918
|
+
return null;
|
|
3919
|
+
} catch (error2) {
|
|
3920
|
+
if (error2 instanceof Error) return error2.message;
|
|
3921
|
+
throw error2;
|
|
3922
|
+
}
|
|
3923
|
+
},
|
|
3924
|
+
/** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
|
|
3925
|
+
addCustomLine(input) {
|
|
3926
|
+
if (locked()) return SALE_SAVING;
|
|
3927
|
+
try {
|
|
3928
|
+
return { id: builderNow.current.addLine({
|
|
3929
|
+
...input,
|
|
3930
|
+
custom: true,
|
|
3931
|
+
productId: `custom:${uuidv7()}`,
|
|
3932
|
+
unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() }
|
|
3933
|
+
}) };
|
|
3934
|
+
} catch (error2) {
|
|
3935
|
+
if (error2 instanceof Error) return error2.message;
|
|
3936
|
+
throw error2;
|
|
3937
|
+
}
|
|
3938
|
+
},
|
|
3757
3939
|
/** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
|
|
3758
3940
|
setUnitPrice(lineId, amountMinor) {
|
|
3759
3941
|
if (locked()) return SALE_SAVING;
|
|
@@ -3921,7 +4103,7 @@ function useSale(settings, opts) {
|
|
|
3921
4103
|
if (stageNow.current.kind !== "cart") return "Finish or cancel the payment before parking the sale";
|
|
3922
4104
|
const builder = builderNow.current;
|
|
3923
4105
|
const snapshot = builder.getSnapshot();
|
|
3924
|
-
if (!snapshot.lineItems.length) return "There is nothing to park";
|
|
4106
|
+
if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return "There is nothing to park";
|
|
3925
4107
|
await writeOrderDraft(opts.drafts, snapshot);
|
|
3926
4108
|
if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return "The sale changed while it was being parked; park it again";
|
|
3927
4109
|
resetSale(null);
|
|
@@ -3933,7 +4115,7 @@ function useSale(settings, opts) {
|
|
|
3933
4115
|
if (!opts.drafts) throw new Error("useSale: resume() needs the drafts option");
|
|
3934
4116
|
const live = builderNow.current;
|
|
3935
4117
|
const liveSnapshot = live.getSnapshot();
|
|
3936
|
-
if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length) return "Park or clear the current sale first";
|
|
4118
|
+
if (stageNow.current.kind !== "cart" || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return "Park or clear the current sale first";
|
|
3937
4119
|
const doc = await opts.drafts.findOne(draftId).exec();
|
|
3938
4120
|
if (!doc) return "That parked sale is no longer there";
|
|
3939
4121
|
const saved = JSON.parse(doc.toJSON().data);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/pos",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared POS business logic for Tally UI — tax, orders, payments, discounts, receipts",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -27,20 +27,20 @@
|
|
|
27
27
|
"directory": "packages/pos"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@tallyui/core": "^3.2.
|
|
30
|
+
"@tallyui/core": "^3.2.1",
|
|
31
31
|
"react": ">=18",
|
|
32
32
|
"rxdb": "~17.5.0",
|
|
33
33
|
"rxjs": ">=7"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@tallyui/core": "3.2.
|
|
36
|
+
"@tallyui/core": "3.2.1",
|
|
37
37
|
"@types/react": ">=18",
|
|
38
38
|
"react": "^19.2.4",
|
|
39
39
|
"rxdb": "17.5.0",
|
|
40
40
|
"rxjs": "7.8.2",
|
|
41
|
-
"@tallyui/connector-medusa": "3.2.
|
|
42
|
-
"@tallyui/connector-woocommerce": "3.2.
|
|
43
|
-
"@tallyui/database": "3.2.
|
|
41
|
+
"@tallyui/connector-medusa": "3.2.1",
|
|
42
|
+
"@tallyui/connector-woocommerce": "3.2.1",
|
|
43
|
+
"@tallyui/database": "3.2.1"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "tsup",
|
package/src/index.ts
CHANGED
package/src/order/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
Payment,
|
|
13
13
|
CustomerSummary,
|
|
14
14
|
AddLineInput,
|
|
15
|
+
ChargeInput, ChargeLine, FeeLine, ShippingLine,
|
|
15
16
|
} from './types';
|
|
16
17
|
|
|
17
18
|
/** Tax contexts already warned that per_rate_group_items fell back to per_order for inclusive lines (#287). */
|
|
@@ -27,7 +28,7 @@ function roundHalfAway(n: number): number {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** The settlement totals of recalculated lines; the display figures derive from them (ADR-063). */
|
|
30
|
-
function sumLines(lines: LineItem[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
|
|
31
|
+
function sumLines(lines: Pick<LineItem, 'netMinor' | 'taxMicros' | 'taxInclusive' | 'taxLines'>[], rounding?: TaxRounding): { subtotalMinor: number; taxMinor: number; totalMinor: number } {
|
|
31
32
|
// per_line_items, per_rate_group_items (#287): total = Σ rounded nets + tax (order-level-tax-calculation-strategy.js:44-49, order.entity.js:88-89).
|
|
32
33
|
const rounded = roundedTaxByRate(lines, rounding);
|
|
33
34
|
if (rounded) return { subtotalMinor: rounded.baseMinor, taxMinor: rounded.taxMinor, totalMinor: Math.max(0, rounded.baseMinor + rounded.taxMinor) };
|
|
@@ -70,6 +71,12 @@ export interface OrderBuilder {
|
|
|
70
71
|
order$: Observable<Order>;
|
|
71
72
|
addProduct(doc: any, traits: ProductTraits, options?: { variantId?: string; quantity?: number }): string;
|
|
72
73
|
addLine(input: AddLineInput): string;
|
|
74
|
+
addFee(input: ChargeInput): string;
|
|
75
|
+
updateFee(id: string, patch: Partial<ChargeInput>): void;
|
|
76
|
+
removeFee(id: string): void;
|
|
77
|
+
addShipping(input: ChargeInput & { methodId?: string }): string;
|
|
78
|
+
updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): void;
|
|
79
|
+
removeShipping(id: string): void;
|
|
73
80
|
updateQuantity(lineId: string, quantity: number): void;
|
|
74
81
|
/** Sets a line's unit price (a till price edit), in minor units of the order's currency and the line's own tax mode; its discounts and tax are recomputed. */
|
|
75
82
|
setUnitPrice(lineId: string, amountMinor: number): void;
|
|
@@ -91,6 +98,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
91
98
|
const orderId = options.id ?? uid();
|
|
92
99
|
|
|
93
100
|
let lineItems: LineItem[] = [];
|
|
101
|
+
let fees: FeeLine[] = [];
|
|
102
|
+
let shipping: ShippingLine[] = [];
|
|
94
103
|
let orderDiscounts: AppliedDiscount[] = [];
|
|
95
104
|
let payments: Payment[] = [];
|
|
96
105
|
let customer: CustomerSummary | null = null;
|
|
@@ -168,8 +177,12 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
168
177
|
const shares = allocateOrderDiscount(lineAmounts, recalcedOrderDiscounts.reduce((sum, d) => sum + d.amountMinor, 0));
|
|
169
178
|
const lines = lineItems.map((li, index) => recalculateLine(li, shares[index]));
|
|
170
179
|
|
|
171
|
-
const { subtotalMinor
|
|
172
|
-
|
|
180
|
+
const { subtotalMinor } = sumLines(lines, taxContext.rounding);
|
|
181
|
+
const charges = [...fees, ...shipping];
|
|
182
|
+
const taxedLines = [...lines, ...charges.map((charge) => ({ ...charge, taxInclusive: taxContext.pricesIncludeTax,
|
|
183
|
+
taxMicros: charge.taxLines.reduce((sum, tax) => sum + BigInt(tax.taxMicros), 0n).toString() }))];
|
|
184
|
+
const { taxMinor, totalMinor } = sumLines(taxedLines, taxContext.rounding);
|
|
185
|
+
if (taxContext.rounding?.granularity === 'per_rate_group_items' && taxedLines.some((li) => li.taxInclusive) && !perRateGroupWarned.has(taxContext)) {
|
|
173
186
|
perRateGroupWarned.add(taxContext);
|
|
174
187
|
taxLogger.warn('per_rate_group_items with inclusive lines: using per_order figures until the display has a rounding row (#287, #310)');
|
|
175
188
|
}
|
|
@@ -197,7 +210,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
197
210
|
const orderDiscountDisplay = figures.reduce((sum, f) => sum + f.shareMinor, 0);
|
|
198
211
|
const displayDiscount = displayLines.reduce(
|
|
199
212
|
(sum, line) => line.discounts.reduce((lineSum, d) => lineSum + d.amountMinor, sum), orderDiscountDisplay);
|
|
200
|
-
const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
|
|
213
|
+
const displaySubtotal = (taxInclusive ? totalMinor : totalMinor - taxMinor) + displayDiscount
|
|
214
|
+
- charges.reduce((sum, charge) => sum + charge.amountMinor, 0);
|
|
201
215
|
const residue = displaySubtotal - displayLines.reduce((sum, line) => sum + line.amountMinor, 0);
|
|
202
216
|
const converted = lines.flatMap((li, index) => li.taxInclusive === taxInclusive ? [] : [index]);
|
|
203
217
|
const conversions = converted.reduce((count, index) => count + [lines[index].netMinor, lines[index].orderDiscountMinor,
|
|
@@ -230,6 +244,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
230
244
|
const display = {
|
|
231
245
|
taxInclusive, subtotalMinor: displaySubtotal, discountMinor: displayDiscount, taxMinor, totalMinor,
|
|
232
246
|
lines: displayLines, orderDiscountMinor: orderDiscountDisplay,
|
|
247
|
+
...(fees.length ? { fees: fees.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
|
|
248
|
+
...(shipping.length ? { shipping: shipping.map(({ id, name, amountMinor }) => ({ id, name, amountMinor })) } : {}),
|
|
233
249
|
};
|
|
234
250
|
const paidMinor = payments.reduce((sum, p) => sum + p.amountMinor, 0);
|
|
235
251
|
const balanceDueMinor = Math.max(0, totalMinor - paidMinor);
|
|
@@ -239,6 +255,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
239
255
|
id: orderId,
|
|
240
256
|
status: 'draft',
|
|
241
257
|
lineItems: lines,
|
|
258
|
+
...(fees.length ? { fees: [...fees] } : {}),
|
|
259
|
+
...(shipping.length ? { shipping: [...shipping] } : {}),
|
|
242
260
|
discounts: recalcedOrderDiscounts,
|
|
243
261
|
payments: [...payments],
|
|
244
262
|
customer,
|
|
@@ -263,6 +281,23 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
263
281
|
subject.next(buildOrder());
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
function recalculateCharge(id: string, input: ChargeInput): ChargeLine {
|
|
285
|
+
if (!Number.isInteger(input.amountMinor)) throw new RangeError('Amount must be integer minor units');
|
|
286
|
+
if (input.amountMinor < 0) throw new RangeError('Amount must be >= 0');
|
|
287
|
+
if (!input.name.trim()) throw new RangeError('Name must not be empty');
|
|
288
|
+
const taxStatus = input.taxStatus ?? 'taxable';
|
|
289
|
+
const code = taxContext.getTaxRateCode?.(input.taxClass);
|
|
290
|
+
const line = recalculateLine({
|
|
291
|
+
id, productId: id, name: input.name, sku: '', unitPriceMinor: input.amountMinor, quantity: 1,
|
|
292
|
+
taxLines: [{ ...(code !== undefined ? { code } : {}),
|
|
293
|
+
ratePpm: taxStatus === 'none' ? 0 : taxContext.getTaxRatePpm(input.taxClass), taxMicros: '0' }],
|
|
294
|
+
discounts: [], discountMinor: 0, orderDiscountMinor: 0, netMinor: 0, taxMicros: '0',
|
|
295
|
+
taxInclusive: taxContext.pricesIncludeTax,
|
|
296
|
+
});
|
|
297
|
+
return { id, name: input.name, amountMinor: input.amountMinor, taxClass: input.taxClass, taxStatus,
|
|
298
|
+
taxLines: line.taxLines, netMinor: line.netMinor, taxMicros: Number(line.taxMicros) };
|
|
299
|
+
}
|
|
300
|
+
|
|
266
301
|
function addLine(input: AddLineInput): string {
|
|
267
302
|
const { productId, variantId, unitPrice } = input;
|
|
268
303
|
const quantity = input.quantity ?? 1;
|
|
@@ -271,11 +306,12 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
271
306
|
if (!Number.isInteger(quantity) || quantity < 1) throw new RangeError('Quantity must be an integer >= 1');
|
|
272
307
|
// A rate from the tax class carries the backend's name for it when one is mapped (#287, #288).
|
|
273
308
|
const code = input.taxRates ? undefined : taxContext.getTaxRateCode?.(input.taxClass);
|
|
274
|
-
const taxRates = input.
|
|
309
|
+
const taxRates = input.taxStatus === 'none' ? [{ ...(code !== undefined ? { code } : {}), ratePpm: 0 }]
|
|
310
|
+
: input.taxRates ?? [{ ...(code !== undefined ? { code } : {}), ratePpm: taxContext.getTaxRatePpm(input.taxClass) }];
|
|
275
311
|
const taxInclusive = unitPrice.taxInclusive ?? taxContext.pricesIncludeTax;
|
|
276
312
|
|
|
277
313
|
const existing = lineItems.find(
|
|
278
|
-
(li) => li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
|
|
314
|
+
(li) => !input.custom && !li.custom && li.productId === productId && li.variantId === variantId && li.taxInclusive === taxInclusive
|
|
279
315
|
&& li.unitPriceMinor === unitPrice.amount && li.taxLines.length === taxRates.length
|
|
280
316
|
&& li.taxLines.every((tax, index) => tax.code === taxRates[index].code && tax.ratePpm === taxRates[index].ratePpm),
|
|
281
317
|
);
|
|
@@ -292,6 +328,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
292
328
|
const line: LineItem = {
|
|
293
329
|
id: lineId,
|
|
294
330
|
productId,
|
|
331
|
+
...(input.custom ? { custom: true as const } : {}),
|
|
332
|
+
...(input.taxStatus === 'none' ? { taxStatus: 'none' as const } : {}),
|
|
295
333
|
variantId,
|
|
296
334
|
name: input.name,
|
|
297
335
|
sku: input.sku ?? '',
|
|
@@ -324,6 +362,36 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
324
362
|
order$: subject.asObservable(),
|
|
325
363
|
addLine,
|
|
326
364
|
|
|
365
|
+
addFee(input) {
|
|
366
|
+
const fee = recalculateCharge(uid(), input);
|
|
367
|
+
fees = [...fees, fee]; emit(); return fee.id;
|
|
368
|
+
},
|
|
369
|
+
updateFee(id, patch) {
|
|
370
|
+
const fee = fees.find((entry) => entry.id === id);
|
|
371
|
+
if (!fee) throw new Error(`Unknown fee ${id}`);
|
|
372
|
+
const updated = recalculateCharge(id, { ...fee, ...patch });
|
|
373
|
+
fees = fees.map((entry) => entry.id === id ? updated : entry); emit();
|
|
374
|
+
},
|
|
375
|
+
removeFee(id) {
|
|
376
|
+
if (!fees.some((entry) => entry.id === id)) throw new Error(`Unknown fee ${id}`);
|
|
377
|
+
fees = fees.filter((entry) => entry.id !== id); emit();
|
|
378
|
+
},
|
|
379
|
+
addShipping(input) {
|
|
380
|
+
const charge = { ...recalculateCharge(uid(), input), methodId: input.methodId };
|
|
381
|
+
shipping = [...shipping, charge]; emit(); return charge.id;
|
|
382
|
+
},
|
|
383
|
+
updateShipping(id, patch) {
|
|
384
|
+
const charge = shipping.find((entry) => entry.id === id);
|
|
385
|
+
if (!charge) throw new Error(`Unknown shipping ${id}`);
|
|
386
|
+
const input = { ...charge, ...patch };
|
|
387
|
+
const updated = { ...recalculateCharge(id, input), methodId: input.methodId };
|
|
388
|
+
shipping = shipping.map((entry) => entry.id === id ? updated : entry); emit();
|
|
389
|
+
},
|
|
390
|
+
removeShipping(id) {
|
|
391
|
+
if (!shipping.some((entry) => entry.id === id)) throw new Error(`Unknown shipping ${id}`);
|
|
392
|
+
shipping = shipping.filter((entry) => entry.id !== id); emit();
|
|
393
|
+
},
|
|
394
|
+
|
|
327
395
|
addProduct(doc, traits, opts) {
|
|
328
396
|
const productId = traits.getId(doc);
|
|
329
397
|
if (!traits.isSellable(doc)) throw new Error("addProduct: this product isn't sold in this store's channel");
|
|
@@ -432,6 +500,8 @@ export function createOrderBuilder(options: OrderBuilderOptions): OrderBuilder {
|
|
|
432
500
|
|
|
433
501
|
clear() {
|
|
434
502
|
lineItems = [];
|
|
503
|
+
fees = [];
|
|
504
|
+
shipping = [];
|
|
435
505
|
orderDiscounts = [];
|
|
436
506
|
payments = [];
|
|
437
507
|
customer = null;
|
|
@@ -65,6 +65,8 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
|
|
|
65
65
|
for (const line of saved.lineItems) {
|
|
66
66
|
const lineId = builder.addLine({
|
|
67
67
|
productId: line.productId,
|
|
68
|
+
custom: line.custom,
|
|
69
|
+
taxStatus: line.taxStatus,
|
|
68
70
|
variantId: line.variantId,
|
|
69
71
|
name: line.name,
|
|
70
72
|
sku: line.sku,
|
|
@@ -83,6 +85,9 @@ export function restoreOrderDraft(saved: Order, options: { currency: string; tax
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
for (const fee of saved.fees ?? []) builder.addFee(fee);
|
|
89
|
+
for (const charge of saved.shipping ?? []) builder.addShipping(charge);
|
|
90
|
+
|
|
86
91
|
// Restore order-level discounts
|
|
87
92
|
for (const discount of saved.discounts) {
|
|
88
93
|
builder.applyOrderDiscount({
|
package/src/order/types.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface Order {
|
|
|
4
4
|
id: string;
|
|
5
5
|
status: 'draft' | 'parked' | 'saved' | 'completed';
|
|
6
6
|
lineItems: LineItem[];
|
|
7
|
+
fees?: FeeLine[];
|
|
8
|
+
shipping?: ShippingLine[];
|
|
7
9
|
discounts: AppliedDiscount[];
|
|
8
10
|
payments: Payment[];
|
|
9
11
|
customer: CustomerSummary | null;
|
|
@@ -34,11 +36,13 @@ export type SentOrder = Omit<Order, 'customer'> & { customer: (Omit<CustomerSumm
|
|
|
34
36
|
*/
|
|
35
37
|
export interface DisplayTotals {
|
|
36
38
|
taxInclusive: boolean; // = order.pricesIncludeTax
|
|
37
|
-
subtotalMinor: number; // before discounts
|
|
39
|
+
subtotalMinor: number; // product lines before discounts, excluding fees and shipping (ADR-075)
|
|
38
40
|
discountMinor: number; // Σ lines' discount rows + orderDiscountMinor, in the display mode; >= 0
|
|
39
41
|
taxMinor: number; // = order.taxMinor (added when exclusive, included when inclusive)
|
|
40
42
|
totalMinor: number; // = order.totalMinor
|
|
41
43
|
lines: DisplayLine[]; // in lineItems order; Σ amountMinor = subtotalMinor
|
|
44
|
+
fees?: Array<{ id: string; name: string; amountMinor: number }>;
|
|
45
|
+
shipping?: Array<{ id: string; name: string; amountMinor: number }>;
|
|
42
46
|
orderDiscountMinor: number; // the order discounts as one row, not allocated: Σ each line's share, converted on its own
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -51,6 +55,8 @@ export interface DisplayLine {
|
|
|
51
55
|
|
|
52
56
|
export interface LineItem {
|
|
53
57
|
id: string;
|
|
58
|
+
custom?: true;
|
|
59
|
+
taxStatus?: 'none';
|
|
54
60
|
productId: string;
|
|
55
61
|
variantId?: string;
|
|
56
62
|
name: string;
|
|
@@ -69,6 +75,20 @@ export interface LineItem {
|
|
|
69
75
|
priceTaxModeConverted?: 'inclusive-to-exclusive' | 'exclusive-to-inclusive';
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
export interface ChargeLine {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
amountMinor: number; // integer >= 0, in the order's tax mode (ADR-075)
|
|
82
|
+
taxClass?: string;
|
|
83
|
+
taxStatus: 'taxable' | 'none';
|
|
84
|
+
taxLines: LineItem['taxLines'];
|
|
85
|
+
netMinor: number;
|
|
86
|
+
taxMicros: number;
|
|
87
|
+
}
|
|
88
|
+
export interface FeeLine extends ChargeLine {}
|
|
89
|
+
export interface ShippingLine extends ChargeLine { methodId?: string }
|
|
90
|
+
export interface ChargeInput { name: string; amountMinor: number; taxClass?: string; taxStatus?: 'taxable' | 'none' }
|
|
91
|
+
|
|
72
92
|
export interface LineTaxLine {
|
|
73
93
|
code?: string;
|
|
74
94
|
ratePpm: number;
|
|
@@ -113,6 +133,8 @@ export interface PaymentMethod {
|
|
|
113
133
|
|
|
114
134
|
export interface AddLineInput {
|
|
115
135
|
productId: string;
|
|
136
|
+
custom?: true;
|
|
137
|
+
taxStatus?: 'taxable' | 'none';
|
|
116
138
|
variantId?: string;
|
|
117
139
|
name: string;
|
|
118
140
|
sku?: string;
|
|
@@ -94,6 +94,9 @@ export function referenceReason(value: string | undefined): 'long' | 'nul' | nul
|
|
|
94
94
|
* customer email or id the shape check would refuse is left out, so `toOrderCreateEnvelope` sends it unchanged.
|
|
95
95
|
*/
|
|
96
96
|
export function finalizeOrder(order: Order, options: FinalizeOptions = {}): PosOrder {
|
|
97
|
+
if (order.fees?.length || order.shipping?.length || order.lineItems.some((line) => line.custom)) {
|
|
98
|
+
throw new Error('finalize: fees, shipping and custom lines need order.create version 5, which this release does not send yet');
|
|
99
|
+
}
|
|
97
100
|
if (!order.lineItems.length) throw new Error('finalize: no lines');
|
|
98
101
|
// Defence in depth: the builder already clamps every discount to >= 0, so this should never fire.
|
|
99
102
|
if (order.discountMinor < 0
|
|
@@ -5,7 +5,10 @@ import type { ReceiptConfig, ReceiptData } from './types';
|
|
|
5
5
|
export function buildReceiptData(order: SentOrder, config: ReceiptConfig): ReceiptData {
|
|
6
6
|
// Drops the shared helper's `netMinor` (the Z report's own use): the receipt's tax summary
|
|
7
7
|
// never showed it, and an exact-shape test elsewhere in this package pins that.
|
|
8
|
-
const
|
|
8
|
+
const taxedLines = [...order.lineItems, ...[...(order.fees ?? []), ...(order.shipping ?? [])].map((charge) => ({
|
|
9
|
+
...charge, taxInclusive: order.pricesIncludeTax,
|
|
10
|
+
}))];
|
|
11
|
+
const taxLines = taxLinesByRate(taxedLines, order.taxMinor, config.taxLabels, order.taxRounding).map(
|
|
9
12
|
({ label, code, ratePpm, amountMinor }) => ({ label, code, ratePpm, amountMinor }),
|
|
10
13
|
);
|
|
11
14
|
|
|
@@ -48,6 +51,8 @@ export function buildReceiptData(order: SentOrder, config: ReceiptConfig): Recei
|
|
|
48
51
|
label: label(d),
|
|
49
52
|
amountMinor: d.amountMinor,
|
|
50
53
|
})),
|
|
54
|
+
fees: (order.display.fees ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
|
|
55
|
+
shipping: (order.display.shipping ?? []).map(({ name, amountMinor }) => ({ name, amountMinor })),
|
|
51
56
|
orderDiscountMinor: order.display.orderDiscountMinor,
|
|
52
57
|
totals: {
|
|
53
58
|
taxInclusive: order.display.taxInclusive,
|
package/src/receipt/types.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface ReceiptData {
|
|
|
21
21
|
register?: string;
|
|
22
22
|
};
|
|
23
23
|
lineItems: ReceiptLineItem[];
|
|
24
|
+
fees: Array<{ name: string; amountMinor: number }>;
|
|
25
|
+
shipping: Array<{ name: string; amountMinor: number }>;
|
|
24
26
|
discounts: { label: string; amountMinor: number }[];
|
|
25
27
|
orderDiscountMinor: number; // the order discounts as one row, in the display mode; + Σ displayDiscounts = totals.discountMinor
|
|
26
28
|
totals: {
|
package/src/sale/use-sale.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { RxCollection } from 'rxdb';
|
|
3
3
|
import type { ProductTraits, ServerCapabilities, StoreSettings } from '@tallyui/core';
|
|
4
|
-
import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
|
|
5
|
-
import { finalizeOrder, type PosOrder } from '../pos-order';
|
|
4
|
+
import { createOrderBuilder, writeOrderDraft, restoreOrderDraft, type ChargeInput, type OrderBuilder, type CustomerSummary, type Discount, type Order, type SentOrder } from '../order';
|
|
5
|
+
import { finalizeOrder, uuidv7, type PosOrder } from '../pos-order';
|
|
6
6
|
import { MESSAGE_NAME_MAX, referenceError, referenceReason, withSentForm } from '../pos-order/finalize';
|
|
7
7
|
import { CUSTOMER_REFUSALS, customerRefusal, cutText, PAYLOAD_STRING_MAX } from '../pos-order/command';
|
|
8
8
|
import { useTax } from '../tax';
|
|
@@ -137,12 +137,12 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
137
137
|
}, [rendered]);
|
|
138
138
|
// New tax settings or currency wait until the sale is idle (an empty cart), then start a new sale keeping its customer:
|
|
139
139
|
// a sale in progress (lines, tender or receipt) finishes on the settings it started with (a money rule).
|
|
140
|
-
const idle = stage.kind === 'cart' && !order.lineItems.length;
|
|
140
|
+
const idle = stage.kind === 'cart' && !order.lineItems.length && !order.fees?.length && !order.shipping?.length;
|
|
141
141
|
// A layout effect, on live idleness (#301): it runs inside the settings' commit, before any tap can be handled, and
|
|
142
142
|
// the rendered `idle` would miss a line added since this render (it would then be dropped with the old builder).
|
|
143
143
|
useLayoutEffect(() => {
|
|
144
144
|
const live = builderNow.current.getSnapshot();
|
|
145
|
-
const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !inFlight.current && !pending.current;
|
|
145
|
+
const liveIdle = stageNow.current.kind === 'cart' && !live.lineItems.length && !live.fees?.length && !live.shipping?.length && !inFlight.current && !pending.current;
|
|
146
146
|
if (liveIdle && (madeWith.current.taxContext !== taxContext || madeWith.current.currency !== settings.currency)) resetSale(live.customer ?? null);
|
|
147
147
|
});
|
|
148
148
|
// A screen that unmounts (medusapos: Sign out) mid-save must still leave a trace of the loss.
|
|
@@ -275,6 +275,50 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
275
275
|
}
|
|
276
276
|
},
|
|
277
277
|
setQuantity(lineId: string, quantity: number) { if (!locked()) builderNow.current.updateQuantity(lineId, quantity); },
|
|
278
|
+
/** ADR-075: adds a fee; returns its id or the builder's refusal. */
|
|
279
|
+
addFee(input: ChargeInput): { id: string } | string {
|
|
280
|
+
if (locked()) return SALE_SAVING;
|
|
281
|
+
try { return { id: builderNow.current.addFee(input) }; }
|
|
282
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
283
|
+
},
|
|
284
|
+
/** ADR-075: updates a fee; returns a refusal or null. */
|
|
285
|
+
updateFee(id: string, patch: Partial<ChargeInput>): string | null {
|
|
286
|
+
if (locked()) return SALE_SAVING;
|
|
287
|
+
try { builderNow.current.updateFee(id, patch); return null; }
|
|
288
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
289
|
+
},
|
|
290
|
+
/** ADR-075: removes a fee; returns a refusal or null. */
|
|
291
|
+
removeFee(id: string): string | null {
|
|
292
|
+
if (locked()) return SALE_SAVING;
|
|
293
|
+
try { builderNow.current.removeFee(id); return null; }
|
|
294
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
295
|
+
},
|
|
296
|
+
/** ADR-075: adds shipping; returns its id or the builder's refusal. */
|
|
297
|
+
addShipping(input: ChargeInput & { methodId?: string }): { id: string } | string {
|
|
298
|
+
if (locked()) return SALE_SAVING;
|
|
299
|
+
try { return { id: builderNow.current.addShipping(input) }; }
|
|
300
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
301
|
+
},
|
|
302
|
+
/** ADR-075: updates shipping; returns a refusal or null. */
|
|
303
|
+
updateShipping(id: string, patch: Partial<ChargeInput & { methodId?: string }>): string | null {
|
|
304
|
+
if (locked()) return SALE_SAVING;
|
|
305
|
+
try { builderNow.current.updateShipping(id, patch); return null; }
|
|
306
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
307
|
+
},
|
|
308
|
+
/** ADR-075: removes shipping; returns a refusal or null. */
|
|
309
|
+
removeShipping(id: string): string | null {
|
|
310
|
+
if (locked()) return SALE_SAVING;
|
|
311
|
+
try { builderNow.current.removeShipping(id); return null; }
|
|
312
|
+
catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
313
|
+
},
|
|
314
|
+
/** ADR-075: adds a distinct custom product line; returns its id or the builder's refusal. */
|
|
315
|
+
addCustomLine(input: { name: string; priceMinor: number; quantity?: number; taxClass?: string; taxStatus?: 'taxable' | 'none'; sku?: string }): { id: string } | string {
|
|
316
|
+
if (locked()) return SALE_SAVING;
|
|
317
|
+
try {
|
|
318
|
+
return { id: builderNow.current.addLine({ ...input, custom: true, productId: `custom:${uuidv7()}`,
|
|
319
|
+
unitPrice: { amount: input.priceMinor, currency: madeWith.current.currency.toUpperCase() } }) };
|
|
320
|
+
} catch (error) { if (error instanceof Error) return error.message; throw error; }
|
|
321
|
+
},
|
|
278
322
|
/** Sets a line's unit price in minor units of its own tax mode, recomputing discounts and tax; returns a refusal or null. */
|
|
279
323
|
setUnitPrice(lineId: string, amountMinor: number): string | null {
|
|
280
324
|
if (locked()) return SALE_SAVING;
|
|
@@ -435,7 +479,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
435
479
|
if (stageNow.current.kind !== 'cart') return 'Finish or cancel the payment before parking the sale';
|
|
436
480
|
const builder = builderNow.current;
|
|
437
481
|
const snapshot = builder.getSnapshot();
|
|
438
|
-
if (!snapshot.lineItems.length) return 'There is nothing to park';
|
|
482
|
+
if (!snapshot.lineItems.length && !snapshot.fees?.length && !snapshot.shipping?.length) return 'There is nothing to park';
|
|
439
483
|
await writeOrderDraft(opts.drafts, snapshot);
|
|
440
484
|
if (builderNow.current !== builder || builder.getSnapshot() !== snapshot) return 'The sale changed while it was being parked; park it again';
|
|
441
485
|
resetSale(null);
|
|
@@ -447,7 +491,7 @@ export function useSale(settings: Pick<StoreSettings, 'currency'>, opts: {
|
|
|
447
491
|
if (!opts.drafts) throw new Error('useSale: resume() needs the drafts option');
|
|
448
492
|
const live = builderNow.current;
|
|
449
493
|
const liveSnapshot = live.getSnapshot();
|
|
450
|
-
if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length) return 'Park or clear the current sale first';
|
|
494
|
+
if (stageNow.current.kind !== 'cart' || liveSnapshot.lineItems.length || liveSnapshot.fees?.length || liveSnapshot.shipping?.length) return 'Park or clear the current sale first';
|
|
451
495
|
const doc = await opts.drafts.findOne(draftId).exec();
|
|
452
496
|
if (!doc) return 'That parked sale is no longer there';
|
|
453
497
|
const saved: Order = JSON.parse(doc.toJSON().data);
|