@develemit/commerce-server 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +5 -1
- package/dist/index.d.ts +76 -4
- package/dist/index.js +138 -26
- package/dist/index.js.map +1 -1
- package/docs/admin.md +11 -10
- package/docs/payments.md +37 -0
- package/migrations/0009_product_published_at.sql +2 -0
- package/migrations/0010_order_tax.sql +1 -0
- package/migrations/0011_product_tax_code.sql +1 -0
- package/migrations/meta/0009_snapshot.json +1035 -0
- package/migrations/meta/0010_snapshot.json +1042 -0
- package/migrations/meta/0011_snapshot.json +1048 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## 0.9.0 (2026-11-14)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- Products can carry an optional Stripe `taxCode`. Migration `0011` adds `commerce_products.tax_code`; the admin API reads and writes it, checkout sends it on that line (`CheckoutLine.taxCode`, which the Stripe gateway passes as `tax_code`), and the public catalog does not expose it.
|
|
6
|
+
- `orderEmailLines` adds a `tax` line (structured, text and HTML, label `labels.tax`, default "Tax") between shipping and total, only when the order's `taxCents` is above 0.
|
|
7
|
+
- Opt-in automatic sales tax through Stripe Tax: `createStripeGateway({ tax: { enabled: true, behavior?, shippingTaxCode? } })` (or `resolvePayment(env, { tax })`) sets `automatic_tax`, a `tax_behavior` on every line and shipping option, and a per-line `taxCode` (`CheckoutLine.taxCode`) when present. The completed-session webhook reads `total_details.amount_tax` into `taxCents`. Without the option, sessions are unchanged. The fake gateway accepts `tax: { rate }` and `taxFor()`, and `devCheckout` records the computed tax.
|
|
8
|
+
- Orders record sales tax. Migration `0010` adds `commerce_orders.tax_cents` (default 0). A `checkout.completed` `PaymentEvent` may carry `taxCents`; it is stored on the order and included in `totalCents`, so full refunds return the tax. Public and admin order responses return `taxCents`. The fake gateway's `completed()` accepts `taxCents`.
|
|
9
|
+
|
|
10
|
+
## 0.8.0 (2026-11-12)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- `DELETE /admin/products/:id` permanently deletes a draft that has never been live and has no order lines (204), along with its image files and holds; otherwise 409 `PRODUCT_NOT_DELETABLE` with `reason: 'published' | 'has_orders'`. Migration `0009` adds `commerce_products.published_at` (set when a product first goes active; backfilled for existing non-draft products), and admin products report `everPublished`.
|
|
15
|
+
- `orderEmailLines` returns `reference`, the short order reference to print in order emails instead of the full id.
|
|
16
|
+
- `GET /admin/orders?q=` accepts the short reference (with or without the leading `#`).
|
|
17
|
+
|
|
18
|
+
### Changes
|
|
19
|
+
|
|
20
|
+
- `GET /admin/products/counts`: `live` counts active pieces with stock, and `sold` counts active pieces at zero stock, so a sold piece is no longer counted in both. Each chip's count equals the list its filter shows (`status=active&stock=in` / `status=active&stock=sold`). Upgrading shows lower Live numbers.
|
|
21
|
+
|
|
1
22
|
## 0.7.0 (2026-11-04)
|
|
2
23
|
|
|
3
24
|
### Features
|
package/README.md
CHANGED
|
@@ -132,8 +132,12 @@ onOrderPaid: async (order) => {
|
|
|
132
132
|
},
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
`lines.reference` is the short order reference (`#A1B2C3D4`, from
|
|
136
|
+
`formatOrderRef` in contracts); print it instead of the full id.
|
|
137
|
+
|
|
135
138
|
The result also has structured `items`, `subtotal`, `discount` (absent when
|
|
136
|
-
no discount applied), `shipping`, `total` and `address`
|
|
139
|
+
no discount applied), `shipping`, `tax` (absent when the order has no tax), `total` and `address`
|
|
140
|
+
(absent for pickup).
|
|
137
141
|
HTML output is `<tr>` / `<p>` fragments with `& < > " '` escaped. Override
|
|
138
142
|
copy with `labels`.
|
|
139
143
|
|
package/dist/index.d.ts
CHANGED
|
@@ -214,6 +214,40 @@ declare const commerceSchema: {
|
|
|
214
214
|
identity: undefined;
|
|
215
215
|
generated: undefined;
|
|
216
216
|
}, {}, {}>;
|
|
217
|
+
taxCode: drizzle_orm_pg_core.PgColumn<{
|
|
218
|
+
name: "tax_code";
|
|
219
|
+
tableName: "commerce_products";
|
|
220
|
+
dataType: "string";
|
|
221
|
+
columnType: "PgText";
|
|
222
|
+
data: string;
|
|
223
|
+
driverParam: string;
|
|
224
|
+
notNull: false;
|
|
225
|
+
hasDefault: false;
|
|
226
|
+
isPrimaryKey: false;
|
|
227
|
+
isAutoincrement: false;
|
|
228
|
+
hasRuntimeDefault: false;
|
|
229
|
+
enumValues: [string, ...string[]];
|
|
230
|
+
baseColumn: never;
|
|
231
|
+
identity: undefined;
|
|
232
|
+
generated: undefined;
|
|
233
|
+
}, {}, {}>;
|
|
234
|
+
publishedAt: drizzle_orm_pg_core.PgColumn<{
|
|
235
|
+
name: "published_at";
|
|
236
|
+
tableName: "commerce_products";
|
|
237
|
+
dataType: "date";
|
|
238
|
+
columnType: "PgTimestamp";
|
|
239
|
+
data: Date;
|
|
240
|
+
driverParam: string;
|
|
241
|
+
notNull: false;
|
|
242
|
+
hasDefault: false;
|
|
243
|
+
isPrimaryKey: false;
|
|
244
|
+
isAutoincrement: false;
|
|
245
|
+
hasRuntimeDefault: false;
|
|
246
|
+
enumValues: undefined;
|
|
247
|
+
baseColumn: never;
|
|
248
|
+
identity: undefined;
|
|
249
|
+
generated: undefined;
|
|
250
|
+
}, {}, {}>;
|
|
217
251
|
createdAt: drizzle_orm_pg_core.PgColumn<{
|
|
218
252
|
name: "created_at";
|
|
219
253
|
tableName: "commerce_products";
|
|
@@ -628,6 +662,23 @@ declare const commerceSchema: {
|
|
|
628
662
|
identity: undefined;
|
|
629
663
|
generated: undefined;
|
|
630
664
|
}, {}, {}>;
|
|
665
|
+
taxCents: drizzle_orm_pg_core.PgColumn<{
|
|
666
|
+
name: "tax_cents";
|
|
667
|
+
tableName: "commerce_orders";
|
|
668
|
+
dataType: "number";
|
|
669
|
+
columnType: "PgInteger";
|
|
670
|
+
data: number;
|
|
671
|
+
driverParam: string | number;
|
|
672
|
+
notNull: true;
|
|
673
|
+
hasDefault: true;
|
|
674
|
+
isPrimaryKey: false;
|
|
675
|
+
isAutoincrement: false;
|
|
676
|
+
hasRuntimeDefault: false;
|
|
677
|
+
enumValues: undefined;
|
|
678
|
+
baseColumn: never;
|
|
679
|
+
identity: undefined;
|
|
680
|
+
generated: undefined;
|
|
681
|
+
}, {}, {}>;
|
|
631
682
|
currency: drizzle_orm_pg_core.PgColumn<{
|
|
632
683
|
name: "currency";
|
|
633
684
|
tableName: "commerce_orders";
|
|
@@ -1669,6 +1720,7 @@ interface CheckoutLine {
|
|
|
1669
1720
|
name: string;
|
|
1670
1721
|
unitAmountCents: number;
|
|
1671
1722
|
quantity: number;
|
|
1723
|
+
taxCode?: string;
|
|
1672
1724
|
}
|
|
1673
1725
|
interface CreateCheckoutSessionInput {
|
|
1674
1726
|
reference: string;
|
|
@@ -1714,6 +1766,7 @@ type PaymentEvent = {
|
|
|
1714
1766
|
email?: string;
|
|
1715
1767
|
shippingAddress?: ShippingAddress;
|
|
1716
1768
|
shipping?: ChosenShipping;
|
|
1769
|
+
taxCents?: number;
|
|
1717
1770
|
paymentPending?: boolean;
|
|
1718
1771
|
} | {
|
|
1719
1772
|
id: string;
|
|
@@ -1827,8 +1880,14 @@ interface StripeGatewayOptions {
|
|
|
1827
1880
|
secretKey: string;
|
|
1828
1881
|
webhookSecret: string;
|
|
1829
1882
|
client?: Stripe;
|
|
1883
|
+
tax?: StripeTaxOptions;
|
|
1884
|
+
}
|
|
1885
|
+
interface StripeTaxOptions {
|
|
1886
|
+
enabled: true;
|
|
1887
|
+
behavior?: 'exclusive' | 'inclusive';
|
|
1888
|
+
shippingTaxCode?: string;
|
|
1830
1889
|
}
|
|
1831
|
-
declare function createStripeGateway({ secretKey, webhookSecret, client, }: StripeGatewayOptions): PaymentGateway;
|
|
1890
|
+
declare function createStripeGateway({ secretKey, webhookSecret, client, tax, }: StripeGatewayOptions): PaymentGateway;
|
|
1832
1891
|
|
|
1833
1892
|
interface SignedWebhook {
|
|
1834
1893
|
rawBody: Buffer;
|
|
@@ -1851,6 +1910,7 @@ interface FakeGateway extends PaymentGateway {
|
|
|
1851
1910
|
email?: string;
|
|
1852
1911
|
shippingAddress?: ShippingAddress;
|
|
1853
1912
|
shipping?: ChosenShipping;
|
|
1913
|
+
taxCents?: number;
|
|
1854
1914
|
paymentPending?: boolean;
|
|
1855
1915
|
}): SignedWebhook;
|
|
1856
1916
|
asyncPaid(sessionId: string): SignedWebhook;
|
|
@@ -1858,11 +1918,16 @@ interface FakeGateway extends PaymentGateway {
|
|
|
1858
1918
|
expired(sessionId: string): SignedWebhook;
|
|
1859
1919
|
refunded(sessionId: string, refunds: GatewayRefundEvent[]): SignedWebhook;
|
|
1860
1920
|
sign(event: PaymentEvent): SignedWebhook;
|
|
1921
|
+
taxFor(sessionId: string, shippingCents?: number): number;
|
|
1922
|
+
}
|
|
1923
|
+
interface FakeTaxOptions {
|
|
1924
|
+
rate: number;
|
|
1861
1925
|
}
|
|
1862
1926
|
interface FakeGatewayOptions {
|
|
1863
1927
|
secret?: string;
|
|
1928
|
+
tax?: FakeTaxOptions;
|
|
1864
1929
|
}
|
|
1865
|
-
declare function createFakeGateway({ secret, }?: FakeGatewayOptions): FakeGateway;
|
|
1930
|
+
declare function createFakeGateway({ secret, tax, }?: FakeGatewayOptions): FakeGateway;
|
|
1866
1931
|
declare function isFakeGateway(gateway: PaymentGateway): gateway is FakeGateway;
|
|
1867
1932
|
|
|
1868
1933
|
interface HoldSweeperOptions {
|
|
@@ -1882,6 +1947,7 @@ interface OrderEmailLabels {
|
|
|
1882
1947
|
subtotal: string;
|
|
1883
1948
|
discount: (code: string | null) => string;
|
|
1884
1949
|
shipping: (label: string | null) => string;
|
|
1950
|
+
tax: string;
|
|
1885
1951
|
total: string;
|
|
1886
1952
|
}
|
|
1887
1953
|
interface OrderEmailLinesOptions {
|
|
@@ -1899,10 +1965,12 @@ interface OrderEmailLinesHtml {
|
|
|
1899
1965
|
address: string;
|
|
1900
1966
|
}
|
|
1901
1967
|
interface OrderEmailLines {
|
|
1968
|
+
reference: string;
|
|
1902
1969
|
items: EmailLine[];
|
|
1903
1970
|
subtotal: EmailLine;
|
|
1904
1971
|
discount?: EmailLine;
|
|
1905
1972
|
shipping: EmailLine;
|
|
1973
|
+
tax?: EmailLine;
|
|
1906
1974
|
total: EmailLine;
|
|
1907
1975
|
address?: string[];
|
|
1908
1976
|
text: OrderEmailLinesText;
|
|
@@ -1968,7 +2036,11 @@ interface PaymentEnv {
|
|
|
1968
2036
|
STRIPE_SECRET_KEY?: string;
|
|
1969
2037
|
STRIPE_WEBHOOK_SECRET?: string;
|
|
1970
2038
|
}
|
|
1971
|
-
|
|
2039
|
+
interface ResolvePaymentOptions {
|
|
2040
|
+
tax?: StripeTaxOptions;
|
|
2041
|
+
fakeTax?: FakeTaxOptions;
|
|
2042
|
+
}
|
|
2043
|
+
declare function resolvePayment(env?: PaymentEnv, { tax, fakeTax }?: ResolvePaymentOptions): PaymentGateway;
|
|
1972
2044
|
interface CheckoutUrlPaths {
|
|
1973
2045
|
successPath?: string;
|
|
1974
2046
|
cancelPath?: string;
|
|
@@ -1976,4 +2048,4 @@ interface CheckoutUrlPaths {
|
|
|
1976
2048
|
declare function checkoutUrls(webUrl: string, { successPath, cancelPath, }?: CheckoutUrlPaths): CheckoutUrls;
|
|
1977
2049
|
|
|
1978
2050
|
export { ProductNotFoundError, SESSION_ID_PLACEHOLDER, WebhookSignatureError, catalogPlugin, checkoutUrls, commercePlugin, commerceSchema, createDiskStorage, createFakeGateway, createMemoryStorage, createStripeGateway, isFakeGateway, openStoredFile, orderEmailLines, ordersPlugin, resolvePayment, runCommerceMigrations, seedProducts, seedShippingRates, startHoldSweeper };
|
|
1979
|
-
export type { AdminOptions, CatalogCacheOptions, CatalogPluginOptions, CategoryLabels, CheckoutDiscount, CheckoutLine, CheckoutSession, CheckoutUrlPaths, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, DevBuyer, DevBuyerSetting, DevCheckoutSetting, DiskStorageOptions, EmailLine, FakeGateway, FakeGatewayOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderEmailLabels, OrderEmailLines, OrderEmailLinesOptions, OrderHooks, OrdersPluginOptions, PaymentEnv, PaymentEvent, PaymentGateway, RefundInput, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StoredFileStream, StripeGatewayOptions };
|
|
2051
|
+
export type { AdminOptions, CatalogCacheOptions, CatalogPluginOptions, CategoryLabels, CheckoutDiscount, CheckoutLine, CheckoutSession, CheckoutUrlPaths, CheckoutUrls, ChosenShipping, CommerceDb, CommercePluginOptions, CommerceSchema, CreateCheckoutSessionInput, DevBuyer, DevBuyerSetting, DevCheckoutSetting, DiskStorageOptions, EmailLine, FakeGateway, FakeGatewayOptions, FakeTaxOptions, FileStorage, GatewayRefund, HoldSweeper, HoldSweeperOptions, MemoryStorage, OrderEmailLabels, OrderEmailLines, OrderEmailLinesOptions, OrderHooks, OrdersPluginOptions, PaymentEnv, PaymentEvent, PaymentGateway, RefundInput, ResolvePaymentOptions, SeedProduct, SeedShippingRate, ShippingAddress, ShippingRateOption, SignedWebhook, StoredFile, StoredFileStream, StripeGatewayOptions, StripeTaxOptions };
|
package/dist/index.js
CHANGED
|
@@ -82,6 +82,8 @@ var products = pgTable(
|
|
|
82
82
|
quantity: integer("quantity").notNull().default(1),
|
|
83
83
|
status: productStatus("status").notNull().default("draft"),
|
|
84
84
|
featured: boolean("featured").notNull().default(false),
|
|
85
|
+
taxCode: text("tax_code"),
|
|
86
|
+
publishedAt: timestamp("published_at", { withTimezone: true }),
|
|
85
87
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
86
88
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
87
89
|
},
|
|
@@ -138,6 +140,7 @@ var orders = pgTable("commerce_orders", {
|
|
|
138
140
|
discountCode: text("discount_code"),
|
|
139
141
|
shippingCents: integer("shipping_cents").notNull().default(0),
|
|
140
142
|
shippingLabel: text("shipping_label"),
|
|
143
|
+
taxCents: integer("tax_cents").notNull().default(0),
|
|
141
144
|
currency: text("currency").notNull(),
|
|
142
145
|
checkoutSessionId: text("checkout_session_id").unique(),
|
|
143
146
|
shippingAddress: jsonb("shipping_address"),
|
|
@@ -331,9 +334,6 @@ async function setShippingRates(db, rates) {
|
|
|
331
334
|
return listShippingRates(db);
|
|
332
335
|
}
|
|
333
336
|
|
|
334
|
-
// src/admin/images.ts
|
|
335
|
-
import { Readable } from "stream";
|
|
336
|
-
|
|
337
337
|
// src/admin/repo.ts
|
|
338
338
|
import {
|
|
339
339
|
and as and2,
|
|
@@ -438,14 +438,23 @@ async function setImageAlt(db, id, alt) {
|
|
|
438
438
|
async function selectProductCounts(db) {
|
|
439
439
|
const [row] = await db.select({
|
|
440
440
|
all: sql2`count(*) filter (where ${products.status} <> 'archived')::int`,
|
|
441
|
-
live: sql2`count(*) filter (where ${products.status} = 'active')::int`,
|
|
441
|
+
live: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} > 0)::int`,
|
|
442
442
|
draft: sql2`count(*) filter (where ${products.status} = 'draft')::int`,
|
|
443
|
-
sold: sql2`count(*) filter (where ${products.quantity} = 0)::int`,
|
|
443
|
+
sold: sql2`count(*) filter (where ${products.status} = 'active' and ${products.quantity} = 0)::int`,
|
|
444
444
|
archived: sql2`count(*) filter (where ${products.status} = 'archived')::int`,
|
|
445
445
|
incompleteDrafts: sql2`count(*) filter (where ${products.status} = 'draft' and (${products.name} = '' or ${products.priceCents} is null or ${products.category} is null))::int`
|
|
446
446
|
}).from(products);
|
|
447
447
|
return row;
|
|
448
448
|
}
|
|
449
|
+
async function productHasOrderLines(db, productId) {
|
|
450
|
+
const rows = await db.select({ id: orderItems.id }).from(orderItems).where(eq2(orderItems.productId, productId)).limit(1);
|
|
451
|
+
return rows.length > 0;
|
|
452
|
+
}
|
|
453
|
+
async function deleteProductRows(db, id) {
|
|
454
|
+
await db.delete(inventoryHolds).where(eq2(inventoryHolds.productId, id));
|
|
455
|
+
await db.delete(productImages).where(eq2(productImages.productId, id));
|
|
456
|
+
await db.delete(products).where(eq2(products.id, id));
|
|
457
|
+
}
|
|
449
458
|
|
|
450
459
|
// src/admin/service.ts
|
|
451
460
|
import {
|
|
@@ -481,6 +490,17 @@ var ProductIncompleteError = class extends Error {
|
|
|
481
490
|
missing;
|
|
482
491
|
code = "PRODUCT_INCOMPLETE";
|
|
483
492
|
};
|
|
493
|
+
var ProductNotDeletableError = class extends Error {
|
|
494
|
+
constructor(reason) {
|
|
495
|
+
super(
|
|
496
|
+
reason === "published" ? "This product has been live, so it can only be archived" : "This product is on an order, so it can only be archived"
|
|
497
|
+
);
|
|
498
|
+
this.reason = reason;
|
|
499
|
+
this.name = "ProductNotDeletableError";
|
|
500
|
+
}
|
|
501
|
+
reason;
|
|
502
|
+
code = "PRODUCT_NOT_DELETABLE";
|
|
503
|
+
};
|
|
484
504
|
|
|
485
505
|
// src/admin/publish.ts
|
|
486
506
|
function temporarySlug(id) {
|
|
@@ -529,6 +549,8 @@ function toAdminProduct({ row, images }) {
|
|
|
529
549
|
quantity: row.quantity,
|
|
530
550
|
status: row.status,
|
|
531
551
|
featured: row.featured,
|
|
552
|
+
everPublished: row.publishedAt !== null,
|
|
553
|
+
taxCode: row.taxCode,
|
|
532
554
|
images: images.map((i) => ({
|
|
533
555
|
id: i.id,
|
|
534
556
|
url: i.url,
|
|
@@ -578,6 +600,7 @@ async function createAdminProduct(db, currency, input, { requirePhotoToPublish =
|
|
|
578
600
|
const slug = uniqueSlug(base, await slugsStartingWith(tx, base));
|
|
579
601
|
return insertProduct(tx, {
|
|
580
602
|
...data,
|
|
603
|
+
publishedAt: data.status === "active" ? /* @__PURE__ */ new Date() : void 0,
|
|
581
604
|
id: newId,
|
|
582
605
|
slug,
|
|
583
606
|
currency: data.currency ?? currency
|
|
@@ -593,6 +616,9 @@ async function patchAdminProduct(db, id, input, { requirePhotoToPublish = true }
|
|
|
593
616
|
assertPublishable(record, fields, photoNeeded);
|
|
594
617
|
}
|
|
595
618
|
const set = sold ? { ...fields, quantity: 0 } : { ...fields };
|
|
619
|
+
if (fields.status === "active" && !record.row.publishedAt) {
|
|
620
|
+
set.publishedAt = /* @__PURE__ */ new Date();
|
|
621
|
+
}
|
|
596
622
|
const renames = fields.name && record.row.slug === temporarySlug(id);
|
|
597
623
|
await db.transaction(async (tx) => {
|
|
598
624
|
if (renames) {
|
|
@@ -607,6 +633,24 @@ async function getAdminProductCounts(db) {
|
|
|
607
633
|
return selectProductCounts(db);
|
|
608
634
|
}
|
|
609
635
|
|
|
636
|
+
// src/admin/delete.ts
|
|
637
|
+
async function deleteAdminProduct(db, storage, id) {
|
|
638
|
+
const { row, images } = await requireProduct(db, id);
|
|
639
|
+
if (row.status !== "draft" || row.publishedAt) {
|
|
640
|
+
throw new ProductNotDeletableError("published");
|
|
641
|
+
}
|
|
642
|
+
if (await productHasOrderLines(db, id)) {
|
|
643
|
+
throw new ProductNotDeletableError("has_orders");
|
|
644
|
+
}
|
|
645
|
+
await db.transaction((tx) => deleteProductRows(tx, id));
|
|
646
|
+
for (const image of images) {
|
|
647
|
+
if (image.storageKey) await storage.delete(image.storageKey);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/admin/images.ts
|
|
652
|
+
import { Readable } from "stream";
|
|
653
|
+
|
|
610
654
|
// src/admin/upload.ts
|
|
611
655
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
612
656
|
import { fileTypeFromBuffer } from "file-type";
|
|
@@ -712,6 +756,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoTo
|
|
|
712
756
|
missing: err.missing
|
|
713
757
|
});
|
|
714
758
|
}
|
|
759
|
+
if (err instanceof ProductNotDeletableError) {
|
|
760
|
+
return reply.code(409).send({
|
|
761
|
+
error: err.code,
|
|
762
|
+
message: err.message,
|
|
763
|
+
reason: err.reason
|
|
764
|
+
});
|
|
765
|
+
}
|
|
715
766
|
if (err instanceof InvalidCursorError || err instanceof InvalidImageError || err instanceof InvalidImageOrderError) {
|
|
716
767
|
return reply.code(400).send({ error: err.code, message: err.message });
|
|
717
768
|
}
|
|
@@ -752,6 +803,13 @@ var adminPlugin = async (app, { db, currency, authorize, storage, requirePhotoTo
|
|
|
752
803
|
{ requirePhotoToPublish }
|
|
753
804
|
)
|
|
754
805
|
);
|
|
806
|
+
app.delete(
|
|
807
|
+
"/products/:id",
|
|
808
|
+
async (req, reply) => {
|
|
809
|
+
await deleteAdminProduct(db, storage, req.params.id);
|
|
810
|
+
return reply.code(204).send();
|
|
811
|
+
}
|
|
812
|
+
);
|
|
755
813
|
app.post(
|
|
756
814
|
"/products/:id/images",
|
|
757
815
|
async (req, reply) => {
|
|
@@ -1171,7 +1229,8 @@ async function findPurchasable(db, ids) {
|
|
|
1171
1229
|
id: row.id,
|
|
1172
1230
|
name: row.name,
|
|
1173
1231
|
priceCents: completeFields(row).priceCents,
|
|
1174
|
-
currency: row.currency
|
|
1232
|
+
currency: row.currency,
|
|
1233
|
+
taxCode: row.taxCode
|
|
1175
1234
|
}));
|
|
1176
1235
|
}
|
|
1177
1236
|
|
|
@@ -1217,6 +1276,9 @@ import {
|
|
|
1217
1276
|
shipOrderInputSchema
|
|
1218
1277
|
} from "@develemit/commerce-contracts";
|
|
1219
1278
|
|
|
1279
|
+
// src/orders/admin-service.ts
|
|
1280
|
+
import { orderRefSearchTerm } from "@develemit/commerce-contracts";
|
|
1281
|
+
|
|
1220
1282
|
// src/orders/admin-repo.ts
|
|
1221
1283
|
import { and as and5, desc as desc2, eq as eq5, ilike as ilike2, lt as lt3, or as or3, sql as sql5 } from "drizzle-orm";
|
|
1222
1284
|
function escapeLike2(value) {
|
|
@@ -1260,7 +1322,8 @@ async function selectOrderPage(db, filter) {
|
|
|
1260
1322
|
id: orders.id,
|
|
1261
1323
|
status: orders.status,
|
|
1262
1324
|
email: orders.email,
|
|
1263
|
-
|
|
1325
|
+
taxCents: orders.taxCents,
|
|
1326
|
+
totalCents: sql5`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents} + ${orders.taxCents}`,
|
|
1264
1327
|
currency: orders.currency,
|
|
1265
1328
|
itemCount,
|
|
1266
1329
|
createdAt: orders.createdAt,
|
|
@@ -1283,7 +1346,7 @@ var isRecordedRefund = or4(
|
|
|
1283
1346
|
async function lockOrderForRefund(tx, id) {
|
|
1284
1347
|
const [row] = await tx.select({
|
|
1285
1348
|
status: orders.status,
|
|
1286
|
-
totalCents: sql6`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
|
|
1349
|
+
totalCents: sql6`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents} + ${orders.taxCents}`,
|
|
1287
1350
|
currency: orders.currency,
|
|
1288
1351
|
checkoutSessionId: orders.checkoutSessionId
|
|
1289
1352
|
}).from(orders).where(eq6(orders.id, id)).for("update");
|
|
@@ -1375,6 +1438,7 @@ async function saveOrderDetails(db, orderId, details, status) {
|
|
|
1375
1438
|
shippingAddress: details.shippingAddress ?? null,
|
|
1376
1439
|
shippingCents: details.shipping?.amountCents ?? 0,
|
|
1377
1440
|
shippingLabel: details.shipping?.label ?? null,
|
|
1441
|
+
taxCents: details.taxCents ?? 0,
|
|
1378
1442
|
updatedAt: sql7`now()`
|
|
1379
1443
|
}).where(eq7(orders.id, orderId));
|
|
1380
1444
|
}
|
|
@@ -1420,7 +1484,8 @@ async function toSummary(db, order) {
|
|
|
1420
1484
|
discountCode: order.discountCode,
|
|
1421
1485
|
shippingCents: order.shippingCents,
|
|
1422
1486
|
shippingLabel: order.shippingLabel,
|
|
1423
|
-
|
|
1487
|
+
taxCents: order.taxCents,
|
|
1488
|
+
totalCents: order.subtotalCents - order.discountCents + order.shippingCents + order.taxCents,
|
|
1424
1489
|
currency: order.currency,
|
|
1425
1490
|
items: items.map((i) => ({
|
|
1426
1491
|
productId: i.productId,
|
|
@@ -1488,7 +1553,7 @@ function decodeOrderCursor(raw) {
|
|
|
1488
1553
|
async function listAdminOrders(db, query) {
|
|
1489
1554
|
const rows = await selectOrderPage(db, {
|
|
1490
1555
|
status: query.status,
|
|
1491
|
-
q: query.q,
|
|
1556
|
+
q: query.q ? orderRefSearchTerm(query.q) || void 0 : void 0,
|
|
1492
1557
|
hasPendingRefund: query.hasPendingRefund,
|
|
1493
1558
|
after: query.cursor ? decodeOrderCursor(query.cursor) : void 0,
|
|
1494
1559
|
limit: query.limit + 1
|
|
@@ -1500,6 +1565,7 @@ async function listAdminOrders(db, query) {
|
|
|
1500
1565
|
id: r.id,
|
|
1501
1566
|
status: r.status,
|
|
1502
1567
|
email: r.email,
|
|
1568
|
+
taxCents: r.taxCents,
|
|
1503
1569
|
totalCents: r.totalCents,
|
|
1504
1570
|
currency: r.currency,
|
|
1505
1571
|
itemCount: r.itemCount,
|
|
@@ -2478,6 +2544,15 @@ function assertDevCheckoutAllowed() {
|
|
|
2478
2544
|
throw new Error("devCheckout is not allowed when NODE_ENV=production");
|
|
2479
2545
|
}
|
|
2480
2546
|
}
|
|
2547
|
+
function completedWithTax(payment, session, buyer) {
|
|
2548
|
+
const details = resolveDevCompletion(
|
|
2549
|
+
buyer,
|
|
2550
|
+
session.id,
|
|
2551
|
+
session.shippingRates[0]
|
|
2552
|
+
);
|
|
2553
|
+
const taxCents = payment.taxFor(session.id, details.shipping?.amountCents);
|
|
2554
|
+
return payment.completed(session.id, { ...details, taxCents });
|
|
2555
|
+
}
|
|
2481
2556
|
var devCheckoutRoutes = async (app, { db, payment, urls, hooks, buyer }) => {
|
|
2482
2557
|
assertDevCheckoutAllowed();
|
|
2483
2558
|
app.get("/checkout/dev/:sessionId/complete", async (req, reply) => {
|
|
@@ -2487,10 +2562,7 @@ var devCheckoutRoutes = async (app, { db, payment, urls, hooks, buyer }) => {
|
|
|
2487
2562
|
return reply.code(404).send({ error: "SESSION_NOT_FOUND" });
|
|
2488
2563
|
}
|
|
2489
2564
|
const expire = req.query.outcome === "expired";
|
|
2490
|
-
const { rawBody, signature } = expire ? payment.expired(sessionId) : payment
|
|
2491
|
-
sessionId,
|
|
2492
|
-
resolveDevCompletion(buyer, sessionId, session.shippingRates[0])
|
|
2493
|
-
);
|
|
2565
|
+
const { rawBody, signature } = expire ? payment.expired(sessionId) : completedWithTax(payment, session, buyer);
|
|
2494
2566
|
const event = await payment.verifyWebhook(rawBody, signature);
|
|
2495
2567
|
await handlePaymentEvent(db, event, hooks, req.log);
|
|
2496
2568
|
return reply.redirect(expire ? urls.cancel : urls.success(sessionId), 302);
|
|
@@ -2574,7 +2646,8 @@ async function priceLines(db, requests, currency) {
|
|
|
2574
2646
|
productId,
|
|
2575
2647
|
name: product.name,
|
|
2576
2648
|
unitAmountCents: product.priceCents,
|
|
2577
|
-
quantity
|
|
2649
|
+
quantity,
|
|
2650
|
+
...product.taxCode && { taxCode: product.taxCode }
|
|
2578
2651
|
};
|
|
2579
2652
|
});
|
|
2580
2653
|
}
|
|
@@ -2764,7 +2837,8 @@ var ordersPlugin = async (app, { db, ...settings }) => {
|
|
|
2764
2837
|
// src/payment/fake-gateway.ts
|
|
2765
2838
|
import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
|
|
2766
2839
|
function createFakeGateway({
|
|
2767
|
-
secret = "fake-webhook-secret"
|
|
2840
|
+
secret = "fake-webhook-secret",
|
|
2841
|
+
tax
|
|
2768
2842
|
} = {}) {
|
|
2769
2843
|
const sessions = [];
|
|
2770
2844
|
const refunds = [];
|
|
@@ -2867,7 +2941,17 @@ function createFakeGateway({
|
|
|
2867
2941
|
refunds: refunds2
|
|
2868
2942
|
});
|
|
2869
2943
|
},
|
|
2870
|
-
sign
|
|
2944
|
+
sign,
|
|
2945
|
+
taxFor(sessionId, shippingCents = 0) {
|
|
2946
|
+
const session = sessions.find((s) => s.id === sessionId);
|
|
2947
|
+
if (!tax || !session) return 0;
|
|
2948
|
+
const merchandise = session.lines.reduce(
|
|
2949
|
+
(sum, l) => sum + l.unitAmountCents * l.quantity,
|
|
2950
|
+
0
|
|
2951
|
+
);
|
|
2952
|
+
const taxable = merchandise - (session.discount?.amountCents ?? 0) + shippingCents;
|
|
2953
|
+
return Math.round(taxable * tax.rate);
|
|
2954
|
+
}
|
|
2871
2955
|
};
|
|
2872
2956
|
}
|
|
2873
2957
|
function isFakeGateway(gateway) {
|
|
@@ -2983,13 +3067,17 @@ function toShippingAddress(name, address) {
|
|
|
2983
3067
|
Object.entries(fields).filter(([, v]) => v !== void 0)
|
|
2984
3068
|
);
|
|
2985
3069
|
}
|
|
2986
|
-
function toShippingOption(rate, currency) {
|
|
3070
|
+
function toShippingOption(rate, currency, tax) {
|
|
2987
3071
|
const days = (value) => ({ unit: "business_day", value });
|
|
2988
3072
|
return {
|
|
2989
3073
|
shipping_rate_data: {
|
|
2990
3074
|
type: "fixed_amount",
|
|
2991
3075
|
display_name: rate.label,
|
|
2992
3076
|
fixed_amount: { amount: rate.amountCents, currency },
|
|
3077
|
+
...tax && {
|
|
3078
|
+
tax_behavior: tax.behavior ?? "exclusive",
|
|
3079
|
+
...tax.shippingTaxCode && { tax_code: tax.shippingTaxCode }
|
|
3080
|
+
},
|
|
2993
3081
|
...rate.minDays !== void 0 && rate.maxDays !== void 0 && {
|
|
2994
3082
|
delivery_estimate: {
|
|
2995
3083
|
minimum: days(rate.minDays),
|
|
@@ -3005,7 +3093,7 @@ function toChosenShipping(session) {
|
|
|
3005
3093
|
if (!cost || !rate || typeof rate === "string") return void 0;
|
|
3006
3094
|
return {
|
|
3007
3095
|
label: rate.display_name ?? "Shipping",
|
|
3008
|
-
amountCents: cost.
|
|
3096
|
+
amountCents: cost.amount_subtotal
|
|
3009
3097
|
};
|
|
3010
3098
|
}
|
|
3011
3099
|
function toPaymentEvent(event, expanded) {
|
|
@@ -3019,6 +3107,9 @@ function toPaymentEvent(event, expanded) {
|
|
|
3019
3107
|
email: session.customer_details?.email ?? void 0,
|
|
3020
3108
|
shippingAddress: toShippingAddress(shipping?.name, shipping?.address),
|
|
3021
3109
|
shipping: expanded && toChosenShipping(expanded),
|
|
3110
|
+
...session.total_details && {
|
|
3111
|
+
taxCents: session.total_details.amount_tax
|
|
3112
|
+
},
|
|
3022
3113
|
paymentPending: session.payment_status === "unpaid"
|
|
3023
3114
|
};
|
|
3024
3115
|
}
|
|
@@ -3087,7 +3178,8 @@ function createCoupon(stripe, { discount, currency, expiresAt }) {
|
|
|
3087
3178
|
function createStripeGateway({
|
|
3088
3179
|
secretKey,
|
|
3089
3180
|
webhookSecret,
|
|
3090
|
-
client
|
|
3181
|
+
client,
|
|
3182
|
+
tax
|
|
3091
3183
|
}) {
|
|
3092
3184
|
let stripe = client ? Promise.resolve(client) : void 0;
|
|
3093
3185
|
const getStripe = () => stripe ??= loadStripe(secretKey);
|
|
@@ -3107,19 +3199,22 @@ function createStripeGateway({
|
|
|
3107
3199
|
price_data: {
|
|
3108
3200
|
currency: input.currency.toLowerCase(),
|
|
3109
3201
|
unit_amount: line.unitAmountCents,
|
|
3202
|
+
...tax && { tax_behavior: tax.behavior ?? "exclusive" },
|
|
3110
3203
|
product_data: {
|
|
3111
3204
|
name: line.name,
|
|
3112
|
-
metadata: { productId: line.productId }
|
|
3205
|
+
metadata: { productId: line.productId },
|
|
3206
|
+
...tax && line.taxCode && { tax_code: line.taxCode }
|
|
3113
3207
|
}
|
|
3114
3208
|
}
|
|
3115
3209
|
})),
|
|
3210
|
+
...tax && { automatic_tax: { enabled: true } },
|
|
3116
3211
|
...coupon && { discounts: [{ coupon: coupon.id }] },
|
|
3117
3212
|
success_url: input.successUrl,
|
|
3118
3213
|
cancel_url: input.cancelUrl,
|
|
3119
3214
|
expires_at: Math.floor(input.expiresAt.getTime() / 1e3),
|
|
3120
3215
|
...input.shippingRates.length > 0 && {
|
|
3121
3216
|
shipping_options: input.shippingRates.map(
|
|
3122
|
-
(rate) => toShippingOption(rate, input.currency.toLowerCase())
|
|
3217
|
+
(rate) => toShippingOption(rate, input.currency.toLowerCase(), tax)
|
|
3123
3218
|
)
|
|
3124
3219
|
},
|
|
3125
3220
|
shipping_address_collection: {
|
|
@@ -3192,10 +3287,14 @@ function startHoldSweeper(db, { intervalMs = 6e4, onError } = {}) {
|
|
|
3192
3287
|
}
|
|
3193
3288
|
|
|
3194
3289
|
// src/emails/order-lines.ts
|
|
3290
|
+
import {
|
|
3291
|
+
formatOrderRef
|
|
3292
|
+
} from "@develemit/commerce-contracts";
|
|
3195
3293
|
var defaultLabels = {
|
|
3196
3294
|
subtotal: "Subtotal",
|
|
3197
3295
|
discount: (code) => code ? `Discount (${code})` : "Discount",
|
|
3198
3296
|
shipping: (label) => label ? `Shipping (${label})` : "Shipping",
|
|
3297
|
+
tax: "Tax",
|
|
3199
3298
|
total: "Total"
|
|
3200
3299
|
};
|
|
3201
3300
|
var escapes = {
|
|
@@ -3251,14 +3350,23 @@ function orderEmailLines(order, options = {}) {
|
|
|
3251
3350
|
label: labels.shipping(order.shippingLabel),
|
|
3252
3351
|
amount: money(order.shippingCents)
|
|
3253
3352
|
};
|
|
3353
|
+
const tax = order.taxCents > 0 ? { label: labels.tax, amount: money(order.taxCents) } : void 0;
|
|
3254
3354
|
const total = { label: labels.total, amount: money(order.totalCents) };
|
|
3255
3355
|
const address = addressLines(order);
|
|
3256
|
-
const totals = [
|
|
3356
|
+
const totals = [
|
|
3357
|
+
subtotal,
|
|
3358
|
+
...discount ? [discount] : [],
|
|
3359
|
+
shipping,
|
|
3360
|
+
...tax ? [tax] : [],
|
|
3361
|
+
total
|
|
3362
|
+
];
|
|
3257
3363
|
return {
|
|
3364
|
+
reference: formatOrderRef(order.id),
|
|
3258
3365
|
items,
|
|
3259
3366
|
subtotal,
|
|
3260
3367
|
discount,
|
|
3261
3368
|
shipping,
|
|
3369
|
+
tax,
|
|
3262
3370
|
total,
|
|
3263
3371
|
address,
|
|
3264
3372
|
text: {
|
|
@@ -3430,7 +3538,7 @@ async function openStoredFile(dir, key) {
|
|
|
3430
3538
|
}
|
|
3431
3539
|
|
|
3432
3540
|
// src/payment/resolve.ts
|
|
3433
|
-
function resolvePayment(env = process.env) {
|
|
3541
|
+
function resolvePayment(env = process.env, { tax, fakeTax } = {}) {
|
|
3434
3542
|
const secretKey = env.STRIPE_SECRET_KEY || void 0;
|
|
3435
3543
|
const webhookSecret = env.STRIPE_WEBHOOK_SECRET || void 0;
|
|
3436
3544
|
if (!secretKey !== !webhookSecret) {
|
|
@@ -3447,9 +3555,13 @@ function resolvePayment(env = process.env) {
|
|
|
3447
3555
|
"The fake payment gateway is not allowed in production; set the Stripe keys"
|
|
3448
3556
|
);
|
|
3449
3557
|
}
|
|
3450
|
-
return createFakeGateway();
|
|
3558
|
+
return createFakeGateway({ tax: fakeTax });
|
|
3451
3559
|
}
|
|
3452
|
-
return createStripeGateway({
|
|
3560
|
+
return createStripeGateway({
|
|
3561
|
+
secretKey,
|
|
3562
|
+
webhookSecret,
|
|
3563
|
+
tax
|
|
3564
|
+
});
|
|
3453
3565
|
}
|
|
3454
3566
|
function checkoutUrls(webUrl, {
|
|
3455
3567
|
successPath = "/checkout/success",
|