@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 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` (absent for pickup).
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
- declare function resolvePayment(env?: PaymentEnv): PaymentGateway;
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
- totalCents: sql5`${orders.subtotalCents} - ${orders.discountCents} + ${orders.shippingCents}`,
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
- totalCents: order.subtotalCents - order.discountCents + order.shippingCents,
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.completed(
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.amount_total
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 = [subtotal, ...discount ? [discount] : [], shipping, total];
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({ secretKey, webhookSecret });
3560
+ return createStripeGateway({
3561
+ secretKey,
3562
+ webhookSecret,
3563
+ tax
3564
+ });
3453
3565
  }
3454
3566
  function checkoutUrls(webUrl, {
3455
3567
  successPath = "/checkout/success",