@stacksjs/commerce 0.75.112 → 0.75.114

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.
Files changed (230) hide show
  1. package/README.md +15 -0
  2. package/dist/auctions/bids/fetch.js +1 -0
  3. package/dist/auctions/bids/index.js +1 -0
  4. package/dist/auctions/bids/place.js +1 -0
  5. package/dist/auctions/engine/anti-snipe.js +1 -0
  6. package/dist/auctions/engine/increments.js +1 -0
  7. package/dist/auctions/engine/index.js +1 -0
  8. package/dist/auctions/engine/proxy.js +1 -0
  9. package/dist/auctions/engine/winners.js +1 -0
  10. package/dist/auctions/index.js +1 -0
  11. package/dist/auctions/lots/fetch.js +1 -0
  12. package/dist/auctions/lots/index.js +1 -0
  13. package/dist/auctions/lots/lifecycle.js +1 -0
  14. package/dist/auctions/notifications.js +1 -0
  15. package/dist/auctions/pledges/index.js +1 -0
  16. package/dist/auctions/realtime.js +1 -0
  17. package/dist/auctions/rules.js +1 -0
  18. package/dist/auctions/types.js +0 -0
  19. package/dist/carts/destroy.js +1 -0
  20. package/dist/carts/fetch.js +1 -0
  21. package/dist/carts/index.js +1 -0
  22. package/dist/carts/store.js +1 -0
  23. package/dist/carts/update.js +1 -0
  24. package/dist/catalog.js +1 -2
  25. package/dist/coupons/destroy.js +1 -0
  26. package/dist/coupons/fetch.js +1 -0
  27. package/dist/coupons/index.js +1 -0
  28. package/dist/coupons/store.js +1 -0
  29. package/dist/coupons/update.js +7 -0
  30. package/dist/customers/destroy.js +1 -0
  31. package/dist/customers/fetch.js +1 -0
  32. package/dist/customers/index.js +1 -0
  33. package/dist/customers/store.js +1 -0
  34. package/dist/customers/update.js +1 -0
  35. package/dist/devices/destroy.js +1 -0
  36. package/dist/devices/export.js +1 -0
  37. package/dist/devices/fetch.js +1 -0
  38. package/dist/devices/index.js +1 -0
  39. package/dist/devices/store.js +1 -0
  40. package/dist/devices/update.js +1 -0
  41. package/dist/errors/destroy.js +1 -0
  42. package/dist/errors/fetch.js +1 -0
  43. package/dist/errors/index.js +1 -0
  44. package/dist/errors/update.js +1 -0
  45. package/dist/gift-cards/destroy.js +1 -0
  46. package/dist/gift-cards/fetch.js +1 -0
  47. package/dist/gift-cards/index.js +1 -0
  48. package/dist/gift-cards/store.js +1 -0
  49. package/dist/gift-cards/update.js +10 -0
  50. package/dist/imports/account-mapping.js +1 -0
  51. package/dist/imports/account-types.js +0 -0
  52. package/dist/imports/account-writer.js +1 -0
  53. package/dist/imports/admin-http.js +1 -0
  54. package/dist/imports/credentials.js +1 -0
  55. package/dist/imports/http.js +1 -0
  56. package/dist/imports/identity.js +1 -0
  57. package/dist/imports/index.js +1 -0
  58. package/dist/imports/money.js +1 -0
  59. package/dist/imports/shopify-admin.js +1 -0
  60. package/dist/imports/shopify.js +1 -0
  61. package/dist/imports/shopware-admin.js +1 -0
  62. package/dist/imports/shopware.js +1 -0
  63. package/dist/imports/text.js +1 -0
  64. package/dist/imports/types.js +0 -0
  65. package/dist/imports/woocommerce-admin.js +1 -0
  66. package/dist/imports/woocommerce.js +1 -0
  67. package/dist/imports/writer.js +1 -0
  68. package/dist/index.d.ts +3 -0
  69. package/dist/index.js +1 -28
  70. package/dist/inventory.d.ts +2 -0
  71. package/dist/inventory.js +1 -0
  72. package/dist/money.js +1 -2
  73. package/dist/orders/destroy.js +1 -0
  74. package/dist/orders/events.js +1 -0
  75. package/dist/orders/export.js +1 -0
  76. package/dist/orders/fetch.js +1 -0
  77. package/dist/orders/guards.js +1 -0
  78. package/dist/orders/index.js +1 -0
  79. package/dist/orders/place-order.js +1 -0
  80. package/dist/orders/store.js +1 -0
  81. package/dist/orders/totals.js +1 -0
  82. package/dist/orders/transition.js +1 -0
  83. package/dist/orders/update.js +1 -0
  84. package/dist/orders/webhook.js +1 -0
  85. package/dist/payments/destroy.js +1 -0
  86. package/dist/payments/fetch.js +1 -0
  87. package/dist/payments/index.js +1 -0
  88. package/dist/payments/store.js +1 -0
  89. package/dist/payments/update.js +8 -0
  90. package/dist/product.d.ts +1 -0
  91. package/dist/product.js +0 -0
  92. package/dist/products/categories/destroy.js +1 -0
  93. package/dist/products/categories/fetch.js +1 -0
  94. package/dist/products/categories/index.js +1 -0
  95. package/dist/products/categories/store.js +1 -0
  96. package/dist/products/categories/types.js +0 -0
  97. package/dist/products/categories/update.js +1 -0
  98. package/dist/products/index.js +1 -0
  99. package/dist/products/items/destroy.js +1 -0
  100. package/dist/products/items/fetch.js +1 -0
  101. package/dist/products/items/index.d.ts +1 -0
  102. package/dist/products/items/index.js +1 -0
  103. package/dist/products/items/store.js +1 -0
  104. package/dist/products/items/update.d.ts +2 -0
  105. package/dist/products/items/update.js +1 -0
  106. package/dist/products/manufacturers/destroy.js +1 -0
  107. package/dist/products/manufacturers/fetch.js +1 -0
  108. package/dist/products/manufacturers/index.js +1 -0
  109. package/dist/products/manufacturers/store.js +1 -0
  110. package/dist/products/manufacturers/update.js +1 -0
  111. package/dist/products/reviews/destroy.js +1 -0
  112. package/dist/products/reviews/fetch.js +1 -0
  113. package/dist/products/reviews/index.js +1 -0
  114. package/dist/products/reviews/stats.js +1 -0
  115. package/dist/products/reviews/store.js +1 -0
  116. package/dist/products/reviews/update.js +1 -0
  117. package/dist/products/units/destroy.js +1 -0
  118. package/dist/products/units/fetch.js +1 -0
  119. package/dist/products/units/index.js +1 -0
  120. package/dist/products/units/store.js +1 -0
  121. package/dist/products/units/types.js +0 -0
  122. package/dist/products/units/update.js +1 -0
  123. package/dist/products/variants/destroy.js +1 -0
  124. package/dist/products/variants/fetch.js +1 -0
  125. package/dist/products/variants/index.js +1 -0
  126. package/dist/products/variants/store.js +1 -0
  127. package/dist/products/variants/update.js +1 -0
  128. package/dist/receipts/destroy.js +1 -0
  129. package/dist/receipts/export.d.ts +1 -0
  130. package/dist/receipts/export.js +0 -0
  131. package/dist/receipts/fetch.js +1 -0
  132. package/dist/receipts/index.js +1 -0
  133. package/dist/receipts/printer.d.ts +37 -0
  134. package/dist/receipts/printer.js +1 -0
  135. package/dist/receipts/store.js +1 -0
  136. package/dist/receipts/timestamp.js +1 -0
  137. package/dist/receipts/update.js +1 -0
  138. package/dist/refund-allocation.js +1 -2
  139. package/dist/register.js +1 -2
  140. package/dist/releases.js +1 -2
  141. package/dist/sales-tax.js +1 -2
  142. package/dist/shippings/couriers/destroy.js +1 -0
  143. package/dist/shippings/couriers/fetch.js +1 -0
  144. package/dist/shippings/couriers/index.js +1 -0
  145. package/dist/shippings/couriers/store.js +1 -0
  146. package/dist/shippings/couriers/update.js +1 -0
  147. package/dist/shippings/delivery-routes/destroy.js +1 -0
  148. package/dist/shippings/delivery-routes/fetch.js +1 -0
  149. package/dist/shippings/delivery-routes/index.js +1 -0
  150. package/dist/shippings/delivery-routes/store.js +1 -0
  151. package/dist/shippings/delivery-routes/update.js +1 -0
  152. package/dist/shippings/delivery-routes/validate-write.js +1 -0
  153. package/dist/shippings/digital-deliveries/destroy.js +1 -0
  154. package/dist/shippings/digital-deliveries/fetch.js +1 -0
  155. package/dist/shippings/digital-deliveries/index.js +1 -0
  156. package/dist/shippings/digital-deliveries/store.js +1 -0
  157. package/dist/shippings/digital-deliveries/update.js +1 -0
  158. package/dist/shippings/geocoding/index.js +1 -0
  159. package/dist/shippings/geocoding/nominatim.js +1 -0
  160. package/dist/shippings/geocoding/types.js +1 -0
  161. package/dist/shippings/index.js +1 -0
  162. package/dist/shippings/license-keys/destroy.js +1 -0
  163. package/dist/shippings/license-keys/fetch.js +1 -0
  164. package/dist/shippings/license-keys/index.js +1 -0
  165. package/dist/shippings/license-keys/store.js +1 -0
  166. package/dist/shippings/license-keys/update.js +1 -0
  167. package/dist/shippings/license-keys/validate-write.js +1 -0
  168. package/dist/shippings/shipping-methods/destroy.js +1 -0
  169. package/dist/shippings/shipping-methods/fetch.js +1 -0
  170. package/dist/shippings/shipping-methods/index.js +1 -0
  171. package/dist/shippings/shipping-methods/store.js +1 -0
  172. package/dist/shippings/shipping-methods/update.js +1 -0
  173. package/dist/shippings/shipping-rates/destroy.js +1 -0
  174. package/dist/shippings/shipping-rates/fetch.js +1 -0
  175. package/dist/shippings/shipping-rates/index.js +1 -0
  176. package/dist/shippings/shipping-rates/store.js +1 -0
  177. package/dist/shippings/shipping-rates/update.js +1 -0
  178. package/dist/shippings/shipping-rates/validate-write.js +1 -0
  179. package/dist/shippings/shipping-zones/destroy.js +1 -0
  180. package/dist/shippings/shipping-zones/fetch.js +1 -0
  181. package/dist/shippings/shipping-zones/index.js +1 -0
  182. package/dist/shippings/shipping-zones/store.js +1 -0
  183. package/dist/shippings/shipping-zones/update.js +1 -0
  184. package/dist/shippings/tracking/events.js +1 -0
  185. package/dist/shippings/tracking/geo.js +1 -0
  186. package/dist/shippings/tracking/index.js +1 -0
  187. package/dist/shippings/tracking/ping.js +1 -0
  188. package/dist/shippings/tracking/stops.js +1 -0
  189. package/dist/shippings/write-data.js +1 -0
  190. package/dist/tax/breakdown.js +1 -0
  191. package/dist/tax/destroy.js +1 -0
  192. package/dist/tax/fetch.js +1 -0
  193. package/dist/tax/index.js +1 -0
  194. package/dist/tax/store.js +1 -0
  195. package/dist/tax/types.js +0 -0
  196. package/dist/tax/update.js +1 -0
  197. package/dist/tax-input.d.ts +7 -0
  198. package/dist/tax-input.js +1 -0
  199. package/dist/tests/fixtures/returning-all-writes.d.ts +1 -0
  200. package/dist/tests/fixtures/returning-all-writes.js +1 -0
  201. package/dist/tests/fixtures/soft-delete-counts.d.ts +1 -0
  202. package/dist/tests/fixtures/soft-delete-counts.js +1 -0
  203. package/dist/tests/fixtures/write-counts.d.ts +1 -0
  204. package/dist/tests/fixtures/write-counts.js +8 -0
  205. package/dist/tests/setup.d.ts +5 -0
  206. package/dist/tests/setup.js +1 -0
  207. package/dist/types.js +0 -0
  208. package/dist/utils/inserted-id.js +1 -0
  209. package/dist/utils/inventory-adjustment.js +5 -0
  210. package/dist/utils/model-row.js +1 -0
  211. package/dist/utils/mutation-count.js +1 -0
  212. package/dist/utils/typed-stats.js +1 -0
  213. package/dist/waitlists/index.js +1 -0
  214. package/dist/waitlists/products/destroy.js +1 -0
  215. package/dist/waitlists/products/export.d.ts +41 -0
  216. package/dist/waitlists/products/export.js +1 -0
  217. package/dist/waitlists/products/fetch.js +1 -0
  218. package/dist/waitlists/products/index.js +1 -0
  219. package/dist/waitlists/products/store.js +1 -0
  220. package/dist/waitlists/products/update.js +1 -0
  221. package/dist/waitlists/products/write-data.js +1 -0
  222. package/dist/waitlists/restaurant/destroy.js +1 -0
  223. package/dist/waitlists/restaurant/export.d.ts +39 -0
  224. package/dist/waitlists/restaurant/export.js +1 -0
  225. package/dist/waitlists/restaurant/fetch.js +1 -0
  226. package/dist/waitlists/restaurant/index.js +1 -0
  227. package/dist/waitlists/restaurant/store.js +1 -0
  228. package/dist/waitlists/restaurant/update.js +1 -0
  229. package/dist/waitlists/restaurant/write-data.js +1 -0
  230. package/package.json +13 -12
package/README.md CHANGED
@@ -79,3 +79,18 @@ For casual chit-chat with others using this package:
79
79
  The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/tree/main/LICENSE.md) for more information.
80
80
 
81
81
  Made with 💙
82
+
83
+ ## Input and inventory abstractions
84
+
85
+ `parseSalesTaxSetup(body, { maxRate?, maxRates? })` requires an explicit rates
86
+ array, rejects coerced prices/text and duplicate normalized component codes,
87
+ and accepts [] only as deliberate removal. HQ-style stores can set maxRate
88
+ to 30; the general default is 100. This validates configuration, not the legal
89
+ rate applicable to a sale. `parseTaxRateWriteData` is also used by native tax
90
+ store/update/bulk operations so false form booleans cannot select a default.
91
+
92
+ `restockedQuantity(current, units, maximum?)` adds positive whole units,
93
+ refusing untracked stock and overflow. `commerce.products.items.restock(id, units)`
94
+ uses that rule under a row lock and reads back on its own connection;
95
+ `updateInventory` validates absolute quantities through the same boundary.
96
+ Pure narrow entries are commerce/inventory and commerce/tax-input.
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";export async function leadingBid(itemId){return await db.selectFrom("bids").selectAll().where("auction_item_id","=",itemId).where("status","=","leading").orderBy("amount","desc").executeTakeFirst()??null}export async function bidsForItem(itemId){return await db.selectFrom("bids").selectAll().where("auction_item_id","=",itemId).orderBy("placed_at","desc").execute()??[]}export async function bidsForAuction(auctionId){return await db.selectFrom("bids").selectAll().where("auction_id","=",auctionId).orderBy("placed_at","asc").execute()??[]}export async function bidsByBidder(auctionId,bidderEmail){return await db.selectFrom("bids").selectAll().where("auction_id","=",auctionId).where("bidder_email","=",bidderEmail.toLowerCase()).orderBy("placed_at","desc").execute()??[]}export async function biddersFor(auctionId){const rows=await db.selectFrom("bids").select(["bidder_name","bidder_email"]).where("auction_id","=",auctionId).execute(),seen=new Map;for(const row of rows??[]){const key=row.bidder_email.toLowerCase();if(!seen.has(key))seen.set(key,{name:row.bidder_name,email:row.bidder_email})}return[...seen.values()]}
@@ -0,0 +1 @@
1
+ export{bidsByBidder,bidsForAuction,bidsForItem,biddersFor,leadingBid}from"./fetch";export{placeBid}from"./place";
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{effectiveCloseAt,extendedCloseAt}from"../engine/anti-snipe";import{nextMinimumBid}from"../engine/increments";import{resolveBid}from"../engine/proxy";import{broadcastBid}from"../realtime";import{rulesFor}from"../rules";import{leadingBid}from"./fetch";function isoOf(value){return value.toISOString().slice(0,19).replace("T"," ")}export async function placeBid(request){const now=request.now??new Date,item=await db.selectFrom("auction_items").selectAll().where("id","=",request.itemId).executeTakeFirst();if(!item)return{accepted:!1,reason:"item_not_found",message:"That lot is no longer available.",nextMinimumBid:0};const auction=await db.selectFrom("auctions").selectAll().where("id","=",item.auction_id).executeTakeFirst(),rules=rulesFor(auction),leader=await leadingBid(item.id),minimum=nextMinimumBid(item,leader?.amount??null,rules.increments);if(!auction||auction.status!=="open"||new Date(auction.opens_at)>now)return{accepted:!1,reason:"auction_not_open",message:"Bidding is not open yet.",nextMinimumBid:minimum,item};const closesAt=effectiveCloseAt(item,auction.closes_at);if(item.status!=="open"||closesAt<=now)return{accepted:!1,reason:"item_not_open",message:"Bidding on this lot has closed.",nextMinimumBid:minimum,item};const resolution=resolveBid(item,leader,{bidderEmail:request.bidderEmail,bidderName:request.bidderName,amount:request.amount,maxAmount:request.maxAmount},rules.increments);if(!resolution.accepted)return{...resolution,item};const extendTo=resolution.buyNow?null:extendedCloseAt(item,closesAt,rules,now),isSelfRaise=resolution.leader&&!resolution.leader.isChallenger&&leader&&leader.bidder_email.toLowerCase()===request.bidderEmail.toLowerCase(),placed=await db.transaction(async(trx)=>{if(isSelfRaise&&leader){await trx.updateTable("bids").set({max_amount:resolution.leader.maxAmount}).where("id","=",leader.id).execute();return{...leader,max_amount:resolution.leader.maxAmount}}const takesLead=resolution.leader?.isChallenger===!0,uuid=randomUUIDv7();await trx.insertInto("bids").values({uuid,auction_id:item.auction_id,auction_item_id:item.id,bidder_name:request.bidderName,bidder_email:request.bidderEmail.toLowerCase(),amount:resolution.challengerAmount??request.amount,max_amount:request.maxAmount??null,status:takesLead?"leading":"outbid",placed_at:isoOf(now)}).execute();if(leader){if(takesLead)await trx.updateTable("bids").set({status:"outbid"}).where("id","=",leader.id).execute();else if(resolution.leader&&resolution.leader.amount!==leader.amount)await trx.updateTable("bids").set({amount:resolution.leader.amount}).where("id","=",leader.id).execute()}if(resolution.buyNow)await trx.updateTable("auction_items").set({status:"sold",closes_at:isoOf(now)}).where("id","=",item.id).execute();else if(extendTo)await trx.updateTable("auction_items").set({closes_at:isoOf(extendTo),extension_count:item.extension_count+1}).where("id","=",item.id).execute();return await trx.selectFrom("bids").selectAll().where("uuid","=",uuid).executeTakeFirst()}),result={...resolution,bid:placed,item:{...item,status:resolution.buyNow?"sold":item.status,closes_at:extendTo?isoOf(extendTo):item.closes_at,extension_count:extendTo?item.extension_count+1:item.extension_count},extendedTo:extendTo??void 0};await broadcastBid(result).catch(()=>{return});return result}
@@ -0,0 +1 @@
1
+ export function extendedCloseAt(item,closesAt,rules,now){if(rules.antiSnipeMinutes<=0||rules.extendOnBidWindowMinutes<=0)return null;if(item.extension_count>=rules.maxExtensions)return null;const msLeft=closesAt.getTime()-now.getTime();if(msLeft<=0)return null;if(msLeft>rules.extendOnBidWindowMinutes*60000)return null;const extended=new Date(now.getTime()+rules.antiSnipeMinutes*60000);return extended>closesAt?extended:null}export function effectiveCloseAt(item,auctionClosesAt){return new Date(item.closes_at??auctionClosesAt)}
@@ -0,0 +1 @@
1
+ export const DEFAULT_INCREMENT_LADDER=[{upTo:5000,step:200},{upTo:1e4,step:500},{upTo:25000,step:1000},{upTo:50000,step:2500},{upTo:1e5,step:5000},{upTo:250000,step:1e4},{upTo:null,step:25000}];export function incrementFor(amount,ladder=DEFAULT_INCREMENT_LADDER,itemIncrement){if(itemIncrement&&itemIncrement>0)return itemIncrement;for(const tier of ladder)if(tier.upTo===null||amount<tier.upTo)return tier.step;return ladder[ladder.length-1]?.step??100}export function nextMinimumBid(item,leadingAmount,ladder=DEFAULT_INCREMENT_LADDER){if(leadingAmount===null||leadingAmount<=0)return item.starting_bid;return leadingAmount+incrementFor(leadingAmount,ladder,item.min_increment)}
@@ -0,0 +1 @@
1
+ export{effectiveCloseAt,extendedCloseAt}from"./anti-snipe";export{DEFAULT_INCREMENT_LADDER,incrementFor,nextMinimumBid}from"./increments";export{resolveBid}from"./proxy";export{determineWinner,settle}from"./winners";
@@ -0,0 +1 @@
1
+ import{DEFAULT_INCREMENT_LADDER,incrementFor,nextMinimumBid}from"./increments";export function resolveBid(item,leader,incoming,ladder=DEFAULT_INCREMENT_LADDER){const ceiling=Math.max(incoming.amount,incoming.maxAmount??0),minimum=nextMinimumBid(item,leader?leader.amount:null,ladder);if(incoming.maxAmount!=null&&incoming.maxAmount<incoming.amount)return{accepted:!1,reason:"max_below_amount",message:"Your maximum bid has to be at least as much as your bid.",nextMinimumBid:minimum};if(ceiling<minimum)return{accepted:!1,reason:"below_minimum",message:`The next bid on this lot is ${minimum/100}.`,nextMinimumBid:minimum};if(item.buy_now_price&&ceiling>=item.buy_now_price)return{accepted:!0,buyNow:!0,leader:{bidderEmail:incoming.bidderEmail,bidderName:incoming.bidderName,amount:item.buy_now_price,maxAmount:item.buy_now_price,isChallenger:!0},challengerAmount:item.buy_now_price,outbid:leader?{bidderEmail:leader.bidder_email,bidderName:leader.bidder_name,amount:leader.amount}:void 0,nextMinimumBid:item.buy_now_price};if(!leader){const amount=Math.max(item.starting_bid,incoming.amount);return{accepted:!0,leader:{bidderEmail:incoming.bidderEmail,bidderName:incoming.bidderName,amount,maxAmount:incoming.maxAmount??null,isChallenger:!0},challengerAmount:amount,nextMinimumBid:amount+incrementFor(amount,ladder,item.min_increment)}}const leaderCeiling=Math.max(leader.amount,leader.max_amount??0);if(leader.bidder_email.toLowerCase()===incoming.bidderEmail.toLowerCase()){if(ceiling<=leaderCeiling)return{accepted:!1,reason:"already_leading",message:"You are already the leading bidder at or above that amount.",nextMinimumBid:minimum};return{accepted:!0,leader:{bidderEmail:leader.bidder_email,bidderName:leader.bidder_name,amount:leader.amount,maxAmount:ceiling,isChallenger:!1},challengerAmount:leader.amount,nextMinimumBid:leader.amount+incrementFor(leader.amount,ladder,item.min_increment)}}if(ceiling>leaderCeiling){const stepped=leaderCeiling+incrementFor(leaderCeiling,ladder,item.min_increment),amount=Math.min(ceiling,Math.max(stepped,minimum));return{accepted:!0,leader:{bidderEmail:incoming.bidderEmail,bidderName:incoming.bidderName,amount,maxAmount:incoming.maxAmount??null,isChallenger:!0},challengerAmount:amount,outbid:{bidderEmail:leader.bidder_email,bidderName:leader.bidder_name,amount:leader.amount},nextMinimumBid:amount+incrementFor(amount,ladder,item.min_increment)}}const stepped=ceiling+incrementFor(ceiling,ladder,item.min_increment),leaderAmount=Math.min(leaderCeiling,Math.max(stepped,ceiling));return{accepted:!0,leader:{bidderEmail:leader.bidder_email,bidderName:leader.bidder_name,amount:leaderAmount,maxAmount:leader.max_amount??null,isChallenger:!1},challengerAmount:ceiling,outbid:{bidderEmail:incoming.bidderEmail,bidderName:incoming.bidderName,amount:ceiling},nextMinimumBid:leaderAmount+incrementFor(leaderAmount,ladder,item.min_increment)}}
@@ -0,0 +1 @@
1
+ export function determineWinner(item,bids){const live=bids.filter((b)=>b.status!=="invalid");if(live.length===0)return{itemId:item.id,lotNumber:item.lot_number,title:item.title,status:"passed",amount:0,passedReason:"no_bids"};const top=[...live].sort((a,b)=>{if(b.amount!==a.amount)return b.amount-a.amount;return new Date(a.placed_at).getTime()-new Date(b.placed_at).getTime()})[0];if(item.reserve_price&&top.amount<item.reserve_price)return{itemId:item.id,lotNumber:item.lot_number,title:item.title,status:"passed",amount:top.amount,passedReason:"reserve_not_met"};return{itemId:item.id,lotNumber:item.lot_number,title:item.title,status:"sold",winningBidId:top.id,winnerName:top.bidder_name,winnerEmail:top.bidder_email,amount:top.amount}}export function settle(items,outcomes,pledges,opts){const sold=outcomes.filter((o)=>o.status==="sold"),passed=outcomes.filter((o)=>o.status==="passed"),bidRevenue=sold.reduce((sum,o)=>sum+o.amount,0),pledgeRevenue=pledges.filter((p)=>p.status==="confirmed").reduce((sum,p)=>sum+p.amount,0),valueByItem=new Map(items.map((i)=>[i.id,i.fair_market_value??null])),valueDelta=sold.reduce((delta,o)=>{const fmv=valueByItem.get(o.itemId);return fmv==null?delta:delta+(o.amount-fmv)},0),offered=outcomes.length;return{auctionId:opts.auctionId,currency:opts.currency,totalRaised:bidRevenue+pledgeRevenue,bidRevenue,pledgeRevenue,goalAmount:opts.goalAmount??null,itemsSold:sold.length,itemsPassed:passed.length,sellThrough:offered===0?0:sold.length/offered,valueDelta,outcomes}}
@@ -0,0 +1 @@
1
+ export*from"./bids";export* as bids from"./bids";export*from"./engine";export* as engine from"./engine";export*from"./lots";export* as lots from"./lots";export*from"./notifications";export*from"./pledges";export* as pledges from"./pledges";export*from"./realtime";export*from"./rules";export*from"./types";
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{nextMinimumBid}from"../engine/increments";import{rulesFor}from"../rules";export async function fetchAuction(id){return await db.selectFrom("auctions").selectAll().where("id","=",id).executeTakeFirst()??null}export async function fetchAuctionForEvent(eventId){return await db.selectFrom("auctions").selectAll().where("event_id","=",eventId).executeTakeFirst()??null}export async function fetchItems(auctionId){return await db.selectFrom("auction_items").selectAll().where("auction_id","=",auctionId).orderBy("lot_number","asc").execute()??[]}export async function fetchCatalogue(auctionId){const[auction,items]=await Promise.all([fetchAuction(auctionId),fetchItems(auctionId)]),rules=rulesFor(auction),leaders=await db.selectFrom("bids").select(["auction_item_id","amount","bidder_name"]).where("auction_id","=",auctionId).where("status","in",["leading","won"]).execute(),counts=await db.selectFrom("bids").select(["auction_item_id"]).where("auction_id","=",auctionId).where("status","!=","invalid").execute(),leaderBy=new Map((leaders??[]).map((l)=>[l.auction_item_id,l])),countBy=new Map;for(const row of counts??[])countBy.set(row.auction_item_id,(countBy.get(row.auction_item_id)??0)+1);return items.map((item)=>{const leader=leaderBy.get(item.id)??null;return{...item,currentBid:leader?.amount??0,bidCount:countBy.get(item.id)??0,nextMinimumBid:nextMinimumBid(item,leader?.amount??null,rules.increments),leaderName:leader?.bidder_name??null}})}export async function auctionTotals(auctionId){const auction=await fetchAuction(auctionId),live=await db.selectFrom("bids").select(["auction_item_id","amount","bidder_email","status"]).where("auction_id","=",auctionId).where("status","!=","invalid").execute(),pledges=await db.selectFrom("pledges").select(["amount"]).where("auction_id","=",auctionId).where("status","=","confirmed").execute(),bids=live??[],currentBidTotal=bids.filter((b)=>b.status==="leading"||b.status==="won").reduce((sum,b)=>sum+b.amount,0),pledgeTotal=(pledges??[]).reduce((sum,p)=>sum+p.amount,0),lotsOffered=await db.selectFrom("auction_items").where("auction_id","=",auctionId).count();return{currentBidTotal,pledgeTotal,raised:currentBidTotal+pledgeTotal,goalAmount:auction?.goal_amount??null,bidCount:bids.length,bidderCount:new Set(bids.map((b)=>b.bidder_email.toLowerCase())).size,lotsOffered:Number(lotsOffered??0),lotsWithBids:new Set(bids.map((b)=>b.auction_item_id)).size}}
@@ -0,0 +1 @@
1
+ export{auctionTotals,fetchAuction,fetchAuctionForEvent,fetchCatalogue,fetchItems}from"./fetch";export{closeAuction,closeDueItems,closeItem,closingSoon,openAuction,settleAuction}from"./lifecycle";
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{effectiveCloseAt}from"../engine/anti-snipe";import{determineWinner,settle}from"../engine/winners";import{broadcastItemClosed}from"../realtime";import{currencyFor}from"../rules";import{fetchAuction,fetchItems}from"./fetch";function isoOf(value){return value.toISOString().slice(0,19).replace("T"," ")}export async function openAuction(auctionId,now=new Date){await db.updateTable("auctions").set({status:"open",opens_at:isoOf(now)}).where("id","=",auctionId).execute();await db.updateTable("auction_items").set({status:"open"}).where("auction_id","=",auctionId).where("status","=","closed").execute();return fetchAuction(auctionId)}export async function closeItem(item,now=new Date){if(item.status==="sold"||item.status==="passed")return null;const bids=await db.selectFrom("bids").selectAll().where("auction_item_id","=",item.id).execute(),outcome=determineWinner(item,bids??[]);await db.transaction(async(trx)=>{await trx.updateTable("auction_items").set({status:outcome.status,closes_at:isoOf(now)}).where("id","=",item.id).execute();if(outcome.winningBidId)await trx.updateTable("bids").set({status:"won"}).where("id","=",outcome.winningBidId).execute();await trx.updateTable("bids").set({status:"lost"}).where("auction_item_id","=",item.id).where("status","in",["leading","outbid"]).execute()});await broadcastItemClosed(item.auction_id,item.id,{status:outcome.status,amount:outcome.amount,winnerName:outcome.winnerName}).catch(()=>{return});return outcome}export async function closeDueItems(auctionId,now=new Date){const auction=await fetchAuction(auctionId);if(!auction)return[];const items=await fetchItems(auctionId),outcomes=[];for(const item of items){if(item.status!=="open")continue;if(effectiveCloseAt(item,auction.closes_at)>now)continue;const outcome=await closeItem(item,now);if(outcome)outcomes.push(outcome)}return outcomes}export async function closeAuction(auctionId,now=new Date){const items=await fetchItems(auctionId),outcomes=[];for(const item of items){const outcome=await closeItem(item,now);if(outcome)outcomes.push(outcome)}await db.updateTable("auctions").set({status:"closed",closes_at:isoOf(now)}).where("id","=",auctionId).execute();return outcomes}export async function settleAuction(auctionId,opts={}){const auction=await fetchAuction(auctionId);if(!auction)return null;const items=await fetchItems(auctionId),bids=await db.selectFrom("bids").selectAll().where("auction_id","=",auctionId).execute(),pledges=await db.selectFrom("pledges").selectAll().where("auction_id","=",auctionId).execute(),bidsByItem=new Map;for(const bid of bids??[]){const list=bidsByItem.get(bid.auction_item_id)??[];list.push(bid);bidsByItem.set(bid.auction_item_id,list)}const outcomes=items.map((item)=>{const outcome=determineWinner(item,bidsByItem.get(item.id)??[]);return item.status==="passed"?{...outcome,status:"passed"}:outcome});if(opts.markSettled)await db.updateTable("auctions").set({status:"settled"}).where("id","=",auctionId).execute();return settle(items,outcomes,pledges??[],{auctionId,currency:currencyFor(auction),goalAmount:auction.goal_amount??null})}export async function closingSoon(auctionId,withinMinutes,now=new Date){const auction=await fetchAuction(auctionId);if(!auction)return[];const cutoff=new Date(now.getTime()+withinMinutes*60000);return(await fetchItems(auctionId)).filter((item)=>{if(item.status!=="open")return!1;const closes=effectiveCloseAt(item,auction.closes_at);return closes>now&&closes<=cutoff})}
@@ -0,0 +1 @@
1
+ function money(cents,currency="USD"){return new Intl.NumberFormat("en-US",{style:"currency",currency,minimumFractionDigits:0}).format(cents/100)}export function outbidNotification(result,currency="USD"){if(!result.outbid||!result.item)return null;const lot=result.item;return{to:{name:result.outbid.bidderName,email:result.outbid.bidderEmail},type:"outbid",subject:`You have been outbid on lot ${lot.lot_number}: ${lot.title}`,body:`Someone has bid higher on "${lot.title}". The next bid is ${money(result.nextMinimumBid,currency)}. Bidding closes ${new Date(lot.closes_at??"").toLocaleString()}.`,data:{itemId:lot.id,lotNumber:lot.lot_number,title:lot.title,nextMinimumBid:result.nextMinimumBid,closesAt:lot.closes_at}}}export function winnerNotification(outcome,currency="USD"){if(outcome.status!=="sold"||!outcome.winnerEmail)return null;return{to:{name:outcome.winnerName??"Bidder",email:outcome.winnerEmail},type:"winner",subject:`You won lot ${outcome.lotNumber}: ${outcome.title}`,body:`Congratulations - you won "${outcome.title}" at ${money(outcome.amount,currency)}. The school will be in touch with payment and pickup details.`,data:{itemId:outcome.itemId,lotNumber:outcome.lotNumber,title:outcome.title,amount:outcome.amount}}}export function closingSoonNotification(item,to,currentBid,currency="USD"){return{to,type:"closing_soon",subject:`Closing soon: lot ${item.lot_number}, ${item.title}`,body:`"${item.title}" closes at ${new Date(item.closes_at??"").toLocaleString()}. The current bid is ${money(currentBid,currency)}.`,data:{itemId:item.id,lotNumber:item.lot_number,title:item.title,currentBid,closesAt:item.closes_at}}}
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{broadcastPledge}from"../realtime";export async function makePledge(request){const uuid=randomUUIDv7();await db.insertInto("pledges").values({uuid,auction_id:request.auctionId,donor_name:request.donorName,donor_email:request.donorEmail.toLowerCase(),amount:request.amount,level:request.level??null,status:request.status??"confirmed"}).execute();const pledge=await db.selectFrom("pledges").selectAll().where("uuid","=",uuid).executeTakeFirst();await broadcastPledge(pledge,await pledgeTotal(request.auctionId)).catch(()=>{return});return pledge}export async function pledgeTotal(auctionId){return(await db.selectFrom("pledges").select(["amount"]).where("auction_id","=",auctionId).where("status","=","confirmed").execute()??[]).reduce((sum,row)=>sum+row.amount,0)}export async function fetchPledges(auctionId){return await db.selectFrom("pledges").selectAll().where("auction_id","=",auctionId).orderBy("amount","desc").execute()??[]}export async function pledgeLevels(auctionId){const pledges=(await fetchPledges(auctionId)).filter((p)=>p.status==="confirmed"),byLevel=new Map;for(const pledge of pledges){const key=pledge.level??"other",entry=byLevel.get(key)??{amount:0,count:0};entry.amount+=pledge.amount;entry.count+=1;byLevel.set(key,entry)}return[...byLevel.entries()].map(([level,stats])=>({level,...stats})).sort((a,b)=>b.amount-a.amount)}
@@ -0,0 +1 @@
1
+ export function auctionChannel(auctionId){return`auction.${auctionId}`}async function emitter(){try{return(await import("@stacksjs/realtime")).emit??null}catch{return null}}export async function broadcastBid(result){const emit=await emitter();if(!emit||!result.item||!result.leader)return;await emit(auctionChannel(result.item.auction_id),"bid.placed",{itemId:result.item.id,lotNumber:result.item.lot_number,amount:result.leader.amount,nextMinimumBid:result.nextMinimumBid,leaderName:result.leader.bidderName,status:result.item.status,closesAt:result.item.closes_at,extended:Boolean(result.extendedTo),soldViaBuyNow:Boolean(result.buyNow)})}export async function broadcastPledge(pledge,runningTotal){const emit=await emitter();if(!emit)return;await emit(auctionChannel(pledge.auction_id),"pledge.made",{donorName:pledge.donor_name,amount:pledge.amount,level:pledge.level??null,runningTotal})}export async function broadcastItemClosed(auctionId,itemId,outcome){const emit=await emitter();if(!emit)return;await emit(auctionChannel(auctionId),"item.closed",{itemId,...outcome})}
@@ -0,0 +1 @@
1
+ import{config}from"@stacksjs/config";import{DEFAULT_INCREMENT_LADDER}from"./engine/increments";function commerceConfig(){return config.commerce??{}}function auctionConfig(){return commerceConfig().auction??{}}export function rulesFor(auction){const cfg=auctionConfig();return{increments:cfg.increments?.length?cfg.increments:DEFAULT_INCREMENT_LADDER,antiSnipeMinutes:auction?.anti_snipe_minutes??cfg.antiSnipeMinutes??2,extendOnBidWindowMinutes:auction?.extend_on_bid_window_minutes??cfg.extendOnBidWindowMinutes??2,maxExtensions:auction?.max_extensions??cfg.maxExtensions??20}}export function currencyFor(auction){return auction?.currency??auctionConfig().currency??commerceConfig().currency??"USD"}
File without changes
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{mutationCount}from"../utils/mutation-count";export async function destroy(id){try{const result=await db.deleteFrom("carts").where("id","=",id).executeTakeFirst();return mutationCount(result)>0}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete cart: ${error.message}`);throw error}}export async function bulkDestroy(ids){if(!ids.length)return 0;try{const result=await db.deleteFrom("carts").where("id","in",ids).executeTakeFirst();return mutationCount(result)}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete carts: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{asModelRow,asModelRows}from"../utils/model-row";export async function fetchById(id){const row=await db.selectFrom("carts").where("id","=",id).selectAll().executeTakeFirst();return asModelRow(row,!0)}export async function fetchAll(){return asModelRows(await db.selectFrom("carts").selectAll().execute())}
@@ -0,0 +1 @@
1
+ export{bulkDestroy,destroy}from"./destroy";export{fetchAll,fetchById}from"./fetch";export{bulkStore,store}from"./store";export{bulkUpdate,update}from"./update";
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";export async function store(data){try{const cartData={...data,uuid:randomUUIDv7()},result=await db.insertInto("carts").values(cartData).returningAll().executeTakeFirst();if(!result)throw Error("Failed to create cart");return asModelRow(result)}catch(error){if(error instanceof Error)throw TypeError(`Failed to create cart: ${error.message}`);throw error}}export async function bulkStore(data){if(!data.length)return 0;let createdCount=0;try{await db.transaction(async(trx)=>{for(const cart of data){const cartData={...cart,uuid:randomUUIDv7()};await trx.insertInto("carts").values(cartData).execute();createdCount++}});return createdCount}catch(error){if(error instanceof Error)throw TypeError(`Failed to create carts in bulk: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db,matchedRows}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";import{formatDate}from"@stacksjs/orm";export async function update(id,data){try{const result=await db.updateTable("carts").set({...data,updated_at:formatDate(new Date)}).where("id","=",id).returningAll().executeTakeFirst();if(!result)throw Error("Failed to update cart");return asModelRow(result)}catch(error){if(error instanceof Error)throw TypeError(`Failed to update cart: ${error.message}`);throw error}}export async function bulkUpdate(data){if(!data.length)return 0;let updatedCount=0;try{await db.transaction(async(trx)=>{for(const cart of data){if(!cart.id)continue;if((await matchedRows(trx.updateTable("carts").set({...cart,updated_at:formatDate(new Date)}).where("id","=",cart.id))).length>0)updatedCount++}});return updatedCount}catch(error){if(error instanceof Error)throw TypeError(`Failed to update carts in bulk: ${error.message}`);throw error}}
package/dist/catalog.js CHANGED
@@ -1,2 +1 @@
1
- // @bun
2
- var u=Object.defineProperty;var g=(e)=>e;function c(e,n){this[e]=g.bind(null,n)}var f=(e,n)=>{for(var r in n)u(e,r,{get:n[r],enumerable:!0,configurable:!0,set:c.bind(n,r)})};var L=(e,n,r)=>()=>{if(e)try{n=e(e=0)}catch(t){r=[t]}if(r)throw r[0];return n};var m={};f(m,{SIZES:()=>l,categoryRank:()=>o,compareCatalog:()=>S,isLowStock:()=>b,matchesSearch:()=>X,sizeOf:()=>a});var l=["XXS","XS","S","M","L","XL","XXL","2XL","3XL","4XL"];function a(e){let n=/^(.*?)\s*[\u2014\u2013\-/]\s*(XXS|XS|S|M|L|XL|XXL|2XL|3XL|4XL)$/i.exec(String(e).trim());if(!n)return{base:String(e).trim(),size:null,rank:-1};let r=n[2].toUpperCase();return{base:n[1],size:r,rank:l.indexOf(r)}}function o(e,n=[]){let r=n.indexOf(String(e||""));if(r!==-1)return r;return e?n.length:n.length+1}function S(e=[]){return(n,r)=>{let t=o(n.category,e)-o(r.category,e)||String(n.category||"").localeCompare(String(r.category||""));if(t)return t;let i=a(String(n.name||"")),s=a(String(r.name||""));return i.base.localeCompare(s.base)||i.rank-s.rank}}function X(e,n){let r=String(n||"").toLowerCase().split(/\s+/).filter(Boolean);if(!r.length)return!0;let t=e.map((i)=>String(i??"")).join(" ").toLowerCase();return r.every((i)=>t.includes(i))}function b(e,n,r=5){if(e===null||e===void 0)return!1;return Number(e)<=(n===null||n===void 0?r:Number(n))}export{l as SIZES,o as categoryRank,S as compareCatalog,b as isLowStock,X as matchesSearch,a as sizeOf};
1
+ export const SIZES=["XXS","XS","S","M","L","XL","XXL","2XL","3XL","4XL"];export function sizeOf(name){const match=/^(.*?)\s*[\u2014\u2013\-/]\s*(XXS|XS|S|M|L|XL|XXL|2XL|3XL|4XL)$/i.exec(String(name).trim());if(!match)return{base:String(name).trim(),size:null,rank:-1};const size=match[2].toUpperCase();return{base:match[1],size,rank:SIZES.indexOf(size)}}export function categoryRank(category,order=[]){const index=order.indexOf(String(category||""));if(index!==-1)return index;return category?order.length:order.length+1}export function compareCatalog(order=[]){return(a,b)=>{const byCategory=categoryRank(a.category,order)-categoryRank(b.category,order)||String(a.category||"").localeCompare(String(b.category||""));if(byCategory)return byCategory;const left=sizeOf(String(a.name||"")),right=sizeOf(String(b.name||""));return left.base.localeCompare(right.base)||left.rank-right.rank}}export function matchesSearch(fields,query){const words=String(query||"").toLowerCase().split(/\s+/).filter(Boolean);if(!words.length)return!0;const haystack=fields.map((field)=>String(field??"")).join(" ").toLowerCase();return words.every((word)=>haystack.includes(word))}export function isLowStock(stock,reorderAt,defaultLow=5){if(stock===null||stock===void 0)return!1;return Number(stock)<=(reorderAt===null||reorderAt===void 0?defaultLow:Number(reorderAt))}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{formatDate}from"@stacksjs/orm";import{mutationCount}from"../utils/mutation-count";export async function deleteCoupon(id){const result=await db.deleteFrom("coupons").where("id","=",id).executeTakeFirst();return mutationCount(result)>0}export async function deleteCoupons(ids){if(!ids.length)return 0;return await db.deleteFrom("coupons").where("id","in",ids).execute()||0}export async function deleteExpiredCoupons(){const currentDate=formatDate(new Date),result=await db.deleteFrom("coupons").where("end_date","<",currentDate).executeTakeFirst();return mutationCount(result)}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{asModelRow,asModelRows}from"../utils/model-row";import{extractDate,formatDate}from"@stacksjs/orm";import{camelCase}from"@stacksjs/strings";function processCouponData(coupon){const processed=Object.fromEntries(Object.entries(coupon).map(([key,value])=>[camelCase(key),value]));if(processed.endDate)processed.endDate=extractDate(new Date(processed.endDate));if(processed.startDate)processed.startDate=extractDate(new Date(processed.startDate));if(processed.isActive!==void 0)processed.isActive=Boolean(processed.isActive);return processed}export async function fetchAll(){const coupons=await db.selectFrom("coupons").selectAll().execute();return asModelRows(coupons).map(processCouponData)}export async function fetchById(id){const coupon=await db.selectFrom("coupons").where("id","=",id).selectAll().executeTakeFirst();if(coupon)return processCouponData(asModelRow(coupon));return}export async function fetchByCode(code){const coupon=await db.selectFrom("coupons").where("code","=",code).selectAll().executeTakeFirst();if(coupon)return processCouponData(asModelRow(coupon));return}export async function fetchActive(){const currentDate=formatDate(new Date),coupons=await db.selectFrom("coupons").where("is_active","=",!0).where("start_date","<=",currentDate).where("end_date",">=",currentDate).selectAll().execute();return asModelRows(coupons).map(processCouponData)}export async function fetchStats(){const totalCoupons=await db.selectFrom("coupons").select(db.fn.count("id").as("count")).executeTakeFirst(),currentDate=formatDate(new Date),activeCoupons=await db.selectFrom("coupons").where("is_active","=",!0).where("start_date","<=",currentDate).where("end_date",">=",currentDate).select(db.fn.count("id").as("count")).executeTakeFirst(),couponsByType=await db.selectFrom("coupons").select(["discount_type",db.fn.count("id").as("count")]).groupBy("discount_type").execute(),mostUsedCoupons=await db.selectFrom("coupons").select(["id","code","discount_type","discount_value","usage_count"]).orderBy("usage_count","desc").limit(5).execute(),upcomingCoupons=await db.selectFrom("coupons").where("is_active","=",!0).where("start_date",">",currentDate).select(["id","code","discount_type","discount_value","start_date","end_date"]).orderBy("start_date","asc").limit(5).execute();return{total:Number(totalCoupons?.count||0),active:Number(activeCoupons?.count||0),by_type:couponsByType,most_used:mostUsedCoupons,upcoming:upcomingCoupons}}export async function fetchCouponCounts(){const currentDate=new Date,weekStart=new Date(currentDate);weekStart.setDate(currentDate.getDate()-7);const monthStart=new Date(currentDate);monthStart.setMonth(currentDate.getMonth()-1);const yearStart=new Date(currentDate);yearStart.setFullYear(currentDate.getFullYear()-1);const allTimeCounts=await fetchCountsForPeriod(null,null,currentDate),weekCounts=await fetchCountsForPeriod(weekStart,null,currentDate),monthCounts=await fetchCountsForPeriod(monthStart,null,currentDate),yearCounts=await fetchCountsForPeriod(yearStart,null,currentDate);return{week:weekCounts,month:monthCounts,year:yearCounts,all_time:allTimeCounts}}async function fetchCountsForPeriod(startDate,endDate,currentDate){let query=db.selectFrom("coupons");if(startDate)query=query.where("created_at",">=",startDate.toISOString());if(endDate)query=query.where("created_at","<=",endDate.toISOString());const totalResult=await query.select(db.fn.count("id").as("count")).executeTakeFirst();let activeQuery=db.selectFrom("coupons");if(startDate)activeQuery=activeQuery.where("created_at",">=",startDate.toISOString());if(endDate)activeQuery=activeQuery.where("created_at","<=",endDate.toISOString());activeQuery=activeQuery.where("is_active","=",!0).where("start_date","<=",currentDate).where("end_date",">=",currentDate);const activeResult=await activeQuery.select(db.fn.count("id").as("count")).executeTakeFirst(),total=Number(totalResult?.count||0),active=Number(activeResult?.count||0),inactive=total-active;return{total,active,inactive}}export async function fetchCouponCountsByType(){const currentDate=new Date,discountTypes=await db.selectFrom("coupons").select("discount_type").distinct().execute(),result={};for(const{discount_type}of discountTypes){const totalResult=await db.selectFrom("coupons").where("discount_type","=",discount_type).select(db.fn.count("id").as("count")).executeTakeFirst(),activeResult=await db.selectFrom("coupons").where("discount_type","=",discount_type).where("is_active","=",!0).where("start_date","<=",currentDate).where("end_date",">=",currentDate).select(db.fn.count("id").as("count")).executeTakeFirst(),total=Number(totalResult?.count||0),active=Number(activeResult?.count||0);result[discount_type]={total,active,inactive:total-active}}return result}export async function fetchRedemptionStats(){const totalResult=await db.selectFrom("coupons").select(db.fn.sum("usage_count").as("total")).executeTakeFirst(),currentDate=new Date,weekStart=new Date(currentDate);weekStart.setDate(currentDate.getDate()-7);const monthStart=new Date(currentDate);monthStart.setMonth(currentDate.getMonth()-1);const yearStart=new Date(currentDate);yearStart.setFullYear(currentDate.getFullYear()-1);const weekResult=await db.selectFrom("coupons").where("updated_at",">=",weekStart.toISOString()).select(db.fn.sum("usage_count").as("total")).executeTakeFirst(),monthResult=await db.selectFrom("coupons").where("updated_at",">=",monthStart.toISOString()).select(db.fn.sum("usage_count").as("total")).executeTakeFirst(),yearResult=await db.selectFrom("coupons").where("updated_at",">=",yearStart.toISOString()).select(db.fn.sum("usage_count").as("total")).executeTakeFirst(),byTypeResults=await db.selectFrom("coupons").select(["discount_type",db.fn.sum("usage_count").as("total")]).groupBy("discount_type").execute(),byType={};byTypeResults.forEach((item)=>{byType[item.discount_type]=Number(item.total||0)});return{total:Number(totalResult?.total||0),week:Number(weekResult?.total||0),month:Number(monthResult?.total||0),year:Number(yearResult?.total||0),by_type:byType}}export async function fetchTopRedeemedCoupons(limit=5){return await db.selectFrom("coupons").select(["id","code","discount_type","discount_value","usage_count","start_date","end_date"]).where("usage_count",">",0).orderBy("usage_count","desc").limit(limit).execute()}export async function fetchRedemptionTrend(days=30){const endDate=new Date().toISOString(),startDate=new Date;startDate.setDate(startDate.getDate()-days);const coupons=await db.selectFrom("coupons").select(["id","code","updated_at","usage_count"]).where("updated_at",">=",startDate.toISOString()).where("updated_at","<=",endDate).where("usage_count",">",0).orderBy("updated_at","desc").execute(),trendData={};coupons.forEach((coupon)=>{const dateString=coupon.updated_at?new Date(coupon.updated_at).toISOString().split("T")[0]??"unknown-date":"unknown-date";if(!trendData[dateString])trendData[dateString]=0;trendData[dateString]+=coupon?.usage_count||0});return Object.entries(trendData).map(([date,count])=>({date,count}))}export async function fetchConversionRate(){const currentDate=new Date,activeResult=await db.selectFrom("coupons").where("is_active","=",!0).where("start_date","<=",currentDate).where("end_date",">=",currentDate).select(db.fn.count("id").as("count")).executeTakeFirst(),redeemedResult=await db.selectFrom("coupons").where("usage_count",">",0).select(db.fn.count("id").as("count")).executeTakeFirst(),totalActive=Number(activeResult?.count||0),totalRedeemed=Number(redeemedResult?.count||0);return{rate:totalActive>0?totalRedeemed/totalActive*100:0,total_active:totalActive,total_redeemed:totalRedeemed}}export async function getActiveCouponsMoMChange(){const today=new Date,currentMonthStart=new Date(today.getFullYear(),today.getMonth(),1),previousMonthStart=new Date(today.getFullYear(),today.getMonth()-1,1),previousMonthEnd=new Date(today.getFullYear(),today.getMonth(),0),currentMonthActive=await db.selectFrom("coupons").select(db.fn.count("id").as("count")).where("is_active","=",!0).where("start_date","<=",today).where("end_date",">=",currentMonthStart).executeTakeFirst(),previousMonthActive=await db.selectFrom("coupons").select(db.fn.count("id").as("count")).where("is_active","=",!0).where("start_date","<=",previousMonthEnd).where("end_date",">=",previousMonthStart).executeTakeFirst(),currentCount=Number(currentMonthActive?.count||0),previousCount=Number(previousMonthActive?.count||0),difference=currentCount-previousCount,percentageChange=previousCount!==0?difference/previousCount*100:currentCount>0?100:0;return{current_month:currentCount,previous_month:previousCount,difference,percentage_change:percentageChange}}
@@ -0,0 +1 @@
1
+ export{deleteCoupon,deleteCoupons,deleteExpiredCoupons}from"./destroy";export{fetchActive,fetchAll,fetchByCode,fetchById,fetchConversionRate,fetchCouponCounts,fetchCouponCountsByType,fetchRedemptionStats,fetchRedemptionTrend,fetchStats,fetchTopRedeemedCoupons,getActiveCouponsMoMChange}from"./fetch";export{store}from"./store";export{redeem,update}from"./update";
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";export async function store(data){try{const uuid=randomUUIDv7();await db.insertInto("coupons").values({...data,uuid}).executeTakeFirst();const coupon=await db.selectFrom("coupons").where("uuid","=",uuid).selectAll().executeTakeFirst();return asModelRow(coupon,!0)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A coupon with this code already exists");if(error instanceof Error)throw Error(`Failed to create coupon: ${error.message}`);throw error}}
@@ -0,0 +1,7 @@
1
+ import{db,mutationCount,sqlHelpers}from"@stacksjs/database/runtime";import{env}from"@stacksjs/env";import{HttpError}from"@stacksjs/error-handling";import{formatDate,isUniqueViolation}from"@stacksjs/orm";import{fetchById}from"./fetch";export async function update(id,data){if(!await fetchById(id))return;try{await db.updateTable("coupons").set({...data,updated_at:formatDate(new Date)}).where("id","=",id).execute();return await fetchById(id)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A coupon with this code already exists");if(error instanceof Error)throw Error(`Failed to update coupon: ${error.message}`);throw error}}export async function redeem(id){const dialect=sqlHelpers(env.DB_CONNECTION||"sqlite"),p=dialect.param,now=formatDate(new Date),statement=await db.unsafe(`UPDATE coupons
2
+ SET usage_count = COALESCE(usage_count, 0) + 1, updated_at = ${p(1)}
3
+ WHERE id = ${p(2)}
4
+ AND (usage_limit IS NULL OR COALESCE(usage_count, 0) < usage_limit)
5
+ AND is_active = ${dialect.boolTrue}
6
+ AND (start_date IS NULL OR start_date <= ${p(3)})
7
+ AND (end_date IS NULL OR end_date >= ${p(4)})`,[now,id,now,now]);if(mutationCount(statement)>0){const refreshed=await fetchById(id);if(refreshed)return{ok:!0,coupon:refreshed};return{ok:!1,reason:"not-found"}}const existing=await fetchById(id);if(!existing)return{ok:!1,reason:"not-found"};if(!existing.isActive)return{ok:!1,reason:"inactive"};const startOk=!existing.startDate||existing.startDate<=now,endOk=!existing.endDate||existing.endDate>=now;if(!startOk||!endOk)return{ok:!1,reason:"expired"};return{ok:!1,reason:"limit-reached"}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{mutationCount}from"../utils/mutation-count";export async function destroy(id){try{const result=await db.deleteFrom("customers").where("id","=",id).executeTakeFirst();return mutationCount(result)>0}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete customer: ${error.message}`);throw error}}export async function bulkDestroy(ids){if(!ids.length)return 0;try{const result=await db.deleteFrom("customers").where("id","in",ids).executeTakeFirst();return mutationCount(result)||0}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete customers in bulk: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{asModelRow,asModelRows}from"../utils/model-row";export async function fetchById(id){const row=await db.selectFrom("customers").where("id","=",id).selectAll().executeTakeFirst();return asModelRow(row,!0)}export async function fetchAll(){return asModelRows(await db.selectFrom("customers").selectAll().execute())}
@@ -0,0 +1 @@
1
+ export{bulkDestroy,destroy}from"./destroy";export{fetchAll,fetchById}from"./fetch";export{store}from"./store";export{update}from"./update";
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{insertedId}from"../utils/inserted-id";import{fetchById}from"./fetch";export const DEFAULT_CUSTOMER_AVATAR="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&fit=crop&q=60";export async function store(data){try{const customerData={...data,avatar:data.avatar??DEFAULT_CUSTOMER_AVATAR},result=await db.insertInto("customers").values(customerData).executeTakeFirst();if(!result)throw Error("Failed to create customer");const id=insertedId(result);if(id===void 0)throw Error("Failed to create customer: the database reported no insert id");return await fetchById(id)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A customer with this email already exists");if(error instanceof Error)throw Error(`Failed to create customer: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";import{HttpError}from"@stacksjs/error-handling";import{formatDate,isUniqueViolation}from"@stacksjs/orm";export async function update(id,data){try{if(!id)throw Error("Customer ID is required for update");const result=await db.updateTable("customers").set({...data,updated_at:formatDate(new Date)}).where("id","=",id).returningAll().executeTakeFirst();if(!result)return;return asModelRow(result)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A customer with this email already exists");if(error instanceof Error)throw Error(`Failed to update customer: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{mutationCount}from"../utils/mutation-count";export async function destroy(id){try{const result=await db.deleteFrom("print_devices").where("id","=",id).executeTakeFirst();return mutationCount(result)>0}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete print device: ${error.message}`);throw error}}export async function bulkDestroy(ids){if(!ids.length)return 0;try{const result=await db.deleteFrom("print_devices").where("id","in",ids).executeTakeFirst();return mutationCount(result)}catch(error){if(error instanceof Error)throw TypeError(`Failed to delete print devices: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{createSpreadsheet}from"ts-spreadsheets";import{fetchAll}from"./fetch";function value(device,camel,snake=camel){const values=device;return values[String(camel)]??values[snake]}function timestamp(value){if(value instanceof Date)return value;if(typeof value==="number")return new Date(value);if(typeof value==="string")return new Date(/^\d+$/.test(value)?Number(value):value);return new Date(Number.NaN)}function numberValue(device,camel,snake=camel){const raw=value(device,camel,snake);if(typeof raw==="number")return raw;const parsed=Number(raw);return Number.isFinite(parsed)?parsed:0}function stringValue(device,camel,snake=camel){const raw=value(device,camel,snake);return raw==null?"":String(raw)}export async function exportPrintDevices(format="csv"){const devices=await fetchAll(),spreadsheetData=prepareDevicesForExport(devices);return createSpreadsheet(spreadsheetData,{type:format})}function prepareDevicesForExport(devices){const headings=["Device ID","Name","MAC Address","Location","Terminal","Status","Last Ping","Print Count"],data=devices.map((device)=>{return[numberValue(device,"id"),stringValue(device,"name"),stringValue(device,"macAddress","mac_address"),stringValue(device,"location"),stringValue(device,"terminal"),stringValue(device,"status"),timestamp(value(device,"lastPing","last_ping")).toLocaleString(),numberValue(device,"printCount","print_count")]});return{headings,data}}export async function downloadPrintDevices(format="csv",filename){const spreadsheet=await exportPrintDevices(format),defaultFilename=`print_devices_export_${new Date().toISOString().split("T")[0]}.${format}`;return spreadsheet.download(filename||defaultFilename)}export async function storePrintDevicesExport(format="csv",path){const spreadsheet=await exportPrintDevices(format),defaultPath=`print_devices_export_${new Date().toISOString().split("T")[0]}.${format}`;spreadsheet.store(path||defaultPath)}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{asModelRow,asModelRows}from"../utils/model-row";export async function fetchById(id){const row=await db.selectFrom("print_devices").where("id","=",id).selectAll().executeTakeFirst();return asModelRow(row,!0)}export async function fetchAll(){return asModelRows(await db.selectFrom("print_devices").selectAll().execute())}export async function countAll(){const result=await db.selectFrom("print_devices").select(db.fn.count("id").as("count")).executeTakeFirst();return Number(result?.count||0)}export async function countTotalPrints(){const result=await db.selectFrom("print_devices").select(db.fn.sum("print_count").as("total")).executeTakeFirst();return Number(result?.total||0)}export async function countPrintsByDeviceId(printDeviceId){const result=await db.selectFrom("print_devices").select("print_count").where("id","=",printDeviceId).executeTakeFirst();return Number(result?.print_count??0)}export async function calculateErrorRate(){const result=await db.selectFrom("receipts").select([db.fn.count("id").as("total"),db.fn.count("id").filterWhere("status","=","failed").as("failed_count")]).executeTakeFirst();if(!result?.total||result.total===0)return 0;return Number(Number(result?.failed_count??0)/Number(result?.total??0)*100)}export async function fetchErrorsByDeviceId(printDeviceId){const rows=await db.selectFrom("receipts").where("print_device_id","=",printDeviceId).where("status","=","failed").selectAll().execute();return asModelRows(rows)}export async function calculatePrinterHealth(){const result=await db.selectFrom("print_devices").select([db.fn.count("id").as("total"),db.fn.count("id").filterWhere("status","=","online").as("online_count")]).executeTakeFirst();if(!result?.total||result.total===0)return 0;return Number(Number(result?.online_count??0)/Number(result?.total??0)*100)}export async function getPrinterStatusCounts(){return(await db.selectFrom("print_devices").select(["status",db.fn.count("id").as("count")]).groupBy("status").execute()).reduce((acc,curr)=>{acc[curr.status]=Number(curr.count||0);return acc},{})}
@@ -0,0 +1 @@
1
+ export{bulkDestroy,destroy}from"./destroy";export{downloadPrintDevices,exportPrintDevices,storePrintDevicesExport}from"./export";export{calculateErrorRate,calculatePrinterHealth,countAll,countPrintsByDeviceId,countTotalPrints,fetchAll,fetchById,fetchErrorsByDeviceId,getPrinterStatusCounts}from"./fetch";export{bulkStore,store}from"./store";export{update,updatePrintCount,updateStatus}from"./update";
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";export async function store(data){try{const uuid=randomUUIDv7(),deviceData={last_ping:0,print_count:0,...data,uuid};await db.insertInto("print_devices").values(deviceData).executeTakeFirst();const printDevice=await db.selectFrom("print_devices").where("uuid","=",uuid).selectAll().executeTakeFirst();return asModelRow(printDevice,!0)}catch(error){if(error instanceof Error)throw TypeError(`Failed to create print device: ${error.message}`);throw error}}export async function bulkStore(data){if(!data.length)return 0;let createdCount=0;try{for(const device of data){const deviceData={last_ping:0,print_count:0,...device,uuid:randomUUIDv7()};await db.insertInto("print_devices").values(deviceData).execute();createdCount++}return createdCount}catch(error){if(error instanceof Error)throw TypeError(`Failed to create print devices in bulk: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{formatDate}from"@stacksjs/orm";import{fetchById}from"./fetch";export async function update(id,data){if(!await fetchById(id))return;try{await db.updateTable("print_devices").set({...data,updated_at:formatDate(new Date)}).where("id","=",id).execute();return await fetchById(id)}catch(error){if(error instanceof Error)throw TypeError(`Failed to update print device: ${error.message}`);throw error}}export async function updateStatus(id,status){if(!await fetchById(id))throw Error(`Print device with ID ${id} not found`);try{await db.updateTable("print_devices").set({status,updated_at:formatDate(new Date)}).where("id","=",id).execute();return await fetchById(id)}catch(error){if(error instanceof Error)throw TypeError(`Failed to update print device status: ${error.message}`);throw error}}export async function updatePrintCount(id,printCount){if(!await fetchById(id))throw Error(`Print device with ID ${id} not found`);try{await db.updateTable("print_devices").set({print_count:printCount,updated_at:formatDate(new Date)}).where("id","=",id).execute();return await fetchById(id)}catch(error){if(error instanceof Error)throw TypeError(`Failed to update print count: ${error.message}`);throw error}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";export async function destroy(id){try{await db.deleteFrom("errors").where("id","=",id).execute();return!0}catch(error){log.error("[commerce/errors] destroy failed",{id,error});return!1}}export async function bulkDestroy(ids){try{await db.deleteFrom("errors").where("id","in",ids).execute();return!0}catch(error){log.error("[commerce/errors] bulkDestroy failed",{count:ids.length,error});return!1}}export async function destroyGroup(type,message){try{await db.deleteFrom("errors").where("type","=",type).where("message","=",message).execute();return!0}catch(error){log.error("[commerce/errors] destroyGroup failed",{type,message,error});return!1}}
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";export async function fetchById(id){return await db.selectFrom("errors").where("id","=",id).selectAll().executeTakeFirst()}export async function fetchAll(){return await db.selectFrom("errors").selectAll().orderBy("created_at","desc").execute()}export async function fetchGrouped(){const errors=await db.selectFrom("errors").selectAll().orderBy("created_at","desc").execute(),groupMap=new Map;for(const error of errors){const key=`${error.type}:${error.message}`;if(!groupMap.has(key))groupMap.set(key,{errors:[],firstSeen:new Date(error.created_at),lastSeen:new Date(error.created_at)});const group=groupMap.get(key);group.errors.push(error);const errorDate=new Date(error.created_at);if(errorDate<group.firstSeen)group.firstSeen=errorDate;if(errorDate>group.lastSeen)group.lastSeen=errorDate}const groupedErrors=[];for(const[_,group]of groupMap){const representative=group.errors[0];if(!representative)continue;const latestStatus=group.errors.find((e)=>e.status!==null)?.status;let statusLabel="unresolved";if(latestStatus===1)statusLabel="resolved";else if(latestStatus===2)statusLabel="ignored";groupedErrors.push({id:representative.id,type:representative.type,message:representative.message,stack:representative.stack,status:statusLabel,count:group.errors.length,first_seen:group.firstSeen.toISOString(),last_seen:group.lastSeen.toISOString(),error_ids:group.errors.map((e)=>e.id)})}groupedErrors.sort((a,b)=>new Date(b.last_seen).getTime()-new Date(a.last_seen).getTime());return groupedErrors}export async function fetchByGroup(type,message){return await db.selectFrom("errors").where("type","=",type).where("message","=",message).selectAll().orderBy("created_at","desc").execute()}export async function fetchStats(){const errors=await fetchAll(),now=new Date,yesterday=new Date(now.getTime()-86400000),twoDaysAgo=new Date(now.getTime()-172800000),last24h=errors.filter((e)=>new Date(e.created_at)>=yesterday).length,previous24h=errors.filter((e)=>{const date=new Date(e.created_at);return date>=twoDaysAgo&&date<yesterday}).length,trend=previous24h===0?last24h>0?100:0:Math.round((last24h-previous24h)/previous24h*100);return{total:errors.length,unresolved:errors.filter((e)=>e.status===null||e.status===0).length,resolved:errors.filter((e)=>e.status===1).length,ignored:errors.filter((e)=>e.status===2).length,last_24h:last24h,trend}}export async function fetchTimeline(){const errors=await fetchAll(),now=new Date,timeline=[];for(let i=23;i>=0;i--){const hourStart=new Date(now.getTime()-i*60*60*1000);hourStart.setMinutes(0,0,0);const hourEnd=new Date(hourStart.getTime()+3600000),count=errors.filter((e)=>{const date=new Date(e.created_at);return date>=hourStart&&date<hourEnd}).length;timeline.push({hour:hourStart.toISOString(),count})}return timeline}
@@ -0,0 +1 @@
1
+ export{bulkDestroy,destroy,destroyGroup}from"./destroy";export{fetchAll,fetchByGroup,fetchById,fetchGrouped,fetchStats,fetchTimeline}from"./fetch";export{bulkUpdateStatus,ignoreGroup,resolveGroup,unresolveGroup,update}from"./update";
@@ -0,0 +1 @@
1
+ import{db}from"@stacksjs/database/runtime";import{sql}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";export async function update(id,data){try{await db.updateTable("errors").set({...data,updated_at:sql.raw("CURRENT_TIMESTAMP")}).where("id","=",id).execute();return!0}catch(error){log.error("[commerce/errors] update failed",{id,error});return!1}}export async function bulkUpdateStatus(ids,status){try{await db.updateTable("errors").set({status,updated_at:sql.raw("CURRENT_TIMESTAMP")}).where("id","in",ids).execute();return!0}catch(error){log.error("[commerce/errors] bulkUpdateStatus failed",{count:ids.length,status,error});return!1}}export async function resolveGroup(type,message){try{await db.updateTable("errors").set({status:1,updated_at:sql.raw("CURRENT_TIMESTAMP")}).where("type","=",type).where("message","=",message).execute();return!0}catch(error){log.error("[commerce/errors] resolveGroup failed",{type,message,error});return!1}}export async function ignoreGroup(type,message){try{await db.updateTable("errors").set({status:2,updated_at:sql.raw("CURRENT_TIMESTAMP")}).where("type","=",type).where("message","=",message).execute();return!0}catch(error){log.error("[commerce/errors] ignoreGroup failed",{type,message,error});return!1}}export async function unresolveGroup(type,message){try{await db.updateTable("errors").set({status:0,updated_at:sql.raw("CURRENT_TIMESTAMP")}).where("type","=",type).where("message","=",message).execute();return!0}catch(error){log.error("[commerce/errors] unresolveGroup failed",{type,message,error});return!1}}
@@ -0,0 +1 @@
1
+ import{db,matchedRows}from"@stacksjs/database/runtime";import{formatDate}from"@stacksjs/orm";import{fetchById}from"./fetch";import{mutationCount}from"../utils/mutation-count";export async function destroy(id){const result=await db.deleteFrom("gift_cards").where("id","=",id).executeTakeFirst();return mutationCount(result)>0}export async function bulkDestroy(ids){if(!ids.length)return 0;const result=await db.deleteFrom("gift_cards").where("id","in",ids).executeTakeFirst();return mutationCount(result)}export async function destroyExpired(){const now=formatDate(new Date),result=await db.deleteFrom("gift_cards").where("expiry_date","<",now).executeTakeFirst();return mutationCount(result)}export async function deactivate(id){if(!await fetchById(id))throw Error(`Gift card with ID ${id} not found`);return(await matchedRows(db.updateTable("gift_cards").set({is_active:!1,status:"DEACTIVATED",updated_at:formatDate(new Date)}).where("id","=",id))).length>0}
@@ -0,0 +1 @@
1
+ import{db,sql}from"@stacksjs/database/runtime";import{formatDate,toTimestamp}from"@stacksjs/orm";import{asModelRow,asModelRows}from"../utils/model-row";import{aggregateStats}from"../utils/typed-stats";export async function fetchAll(){const giftCards=await db.selectFrom("gift_cards").selectAll().execute();return asModelRows(giftCards)}export async function fetchById(id){const row=await db.selectFrom("gift_cards").where("id","=",id).selectAll().executeTakeFirst();return asModelRow(row,!0)}export async function fetchByCode(code){const row=await db.selectFrom("gift_cards").where("code","=",code).selectAll().executeTakeFirst();return asModelRow(row,!0)}export async function fetchActive(){const giftCards=await db.selectFrom("gift_cards").where("is_active","=",!0).selectAll().execute();return asModelRows(giftCards)}export async function fetchStats(){const{count:totalCount}=await aggregateStats("gift_cards",{count:{kind:"count"}}),currentDate=toTimestamp(new Date),activeGiftCards=await db.selectFrom("gift_cards").where("is_active","=",!0).where("status","=","ACTIVE").whereRaw(sql`(expiry_date >= ${sql.literal(currentDate)} OR expiry_date IS NULL)`).select(db.fn.count("id").as("count")).executeTakeFirst(),giftCardsByStatus=await db.selectFrom("gift_cards").select(["status",db.fn.count("id").as("count")]).groupBy("status").execute(),{count:lowBalanceCount}=await aggregateStats("gift_cards",{count:{kind:"count"}},(qb)=>qb.whereRaw(sql`current_balance / initial_balance < 0.25`)),{count:mediumBalanceCount}=await aggregateStats("gift_cards",{count:{kind:"count"}},(qb)=>qb.whereRaw(sql`current_balance / initial_balance >= 0.25 AND current_balance / initial_balance <= 0.75`)),{count:highBalanceCount}=await aggregateStats("gift_cards",{count:{kind:"count"}},(qb)=>qb.whereRaw(sql`current_balance / initial_balance > 0.75`)),thirtyDaysFromNow=new Date;thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate()+30);const expiringGiftCards=await db.selectFrom("gift_cards").where("is_active","=",!0).where("status","=","ACTIVE").where("expiry_date","<=",toTimestamp(thirtyDaysFromNow)).where("expiry_date",">=",currentDate).selectAll().limit(5).execute(),recentlyUsedGiftCards=await db.selectFrom("gift_cards").whereNotNull("last_used_date").selectAll().limit(5).execute();return{total:totalCount,active:Number(activeGiftCards?.count||0),by_status:giftCardsByStatus.map((item)=>({status:item.status,count:Number(item.count)})),by_balance:{low:lowBalanceCount,medium:mediumBalanceCount,high:highBalanceCount},expiring_soon:asModelRows(expiringGiftCards),recently_used:asModelRows(recentlyUsedGiftCards)}}export async function checkBalance(code){const giftCard=await fetchByCode(code);if(!giftCard)return{valid:!1,message:"Gift card not found"};if(!giftCard.isActive||giftCard.status!=="ACTIVE")return{valid:!1,balance:giftCard.currentBalance,currency:giftCard.currency,message:`Gift card is ${giftCard.status.toLowerCase()}`};if(giftCard.expiryDate){if(new Date(giftCard.expiryDate)<new Date)return{valid:!1,balance:giftCard.currentBalance,currency:giftCard.currency,message:"Gift card has expired"}}return{valid:!0,balance:giftCard.currentBalance,currency:giftCard.currency}}export async function compareActiveGiftCards(_daysRange=30){const today=new Date,currentPeriodStart=new Date(today);currentPeriodStart.setDate(today.getDate()-_daysRange);const previousPeriodEnd=new Date(currentPeriodStart);previousPeriodEnd.setDate(previousPeriodEnd.getDate()-1);const previousPeriodStart=new Date(previousPeriodEnd);previousPeriodStart.setDate(previousPeriodEnd.getDate()-_daysRange);const currentPeriodActive=await db.selectFrom("gift_cards").select(db.fn.count("id").as("count")).where("is_active","=",!0).where("status","=","ACTIVE").whereRaw(sql`(expiry_date >= ${sql.literal(toTimestamp(today))} OR expiry_date IS NULL)`).where("created_at",">=",formatDate(currentPeriodStart)).where("created_at","<=",formatDate(today)).executeTakeFirst(),previousPeriodActive=await db.selectFrom("gift_cards").select(db.fn.count("id").as("count")).where("is_active","=",!0).where("status","=","ACTIVE").whereRaw(sql`(expiry_date >= ${sql.literal(toTimestamp(previousPeriodStart))} OR expiry_date IS NULL)`).where("created_at",">=",formatDate(previousPeriodStart)).where("created_at","<=",formatDate(previousPeriodEnd)).executeTakeFirst(),currentCount=Number(currentPeriodActive?.count||0),previousCount=Number(previousPeriodActive?.count||0),difference=currentCount-previousCount,percentageChange=previousCount!==0?difference/previousCount*100:currentCount>0?100:0;return{current_period:currentCount,previous_period:previousCount,difference,percentage_change:percentageChange,days_range:_daysRange}}export async function calculateGiftCardValues(_daysRange=30){const today=new Date,currentPeriodStart=new Date(today);currentPeriodStart.setDate(today.getDate()-_daysRange);const previousPeriodEnd=new Date(currentPeriodStart);previousPeriodEnd.setDate(previousPeriodEnd.getDate()-1);const previousPeriodStart=new Date(previousPeriodEnd);previousPeriodStart.setDate(previousPeriodEnd.getDate()-_daysRange);const currentPeriodValues=await db.selectFrom("gift_cards").select([db.fn.sum("initial_balance").as("total_initial"),db.fn.sum("current_balance").as("total_balance"),db.fn.count("id").as("card_count")]).where("is_active","=",!0).where("status","=","ACTIVE").whereRaw(sql`(expiry_date >= ${sql.literal(toTimestamp(today))} OR expiry_date IS NULL)`).where("created_at",">=",formatDate(currentPeriodStart)).where("created_at","<=",formatDate(today)).executeTakeFirst(),previousPeriodValues=await db.selectFrom("gift_cards").select([db.fn.sum("initial_balance").as("total_initial"),db.fn.sum("current_balance").as("total_balance"),db.fn.count("id").as("card_count")]).where("is_active","=",!0).where("status","=","ACTIVE").whereRaw(sql`(expiry_date >= ${sql.literal(toTimestamp(previousPeriodStart))} OR expiry_date IS NULL)`).where("created_at",">=",formatDate(previousPeriodStart)).where("created_at","<=",formatDate(previousPeriodEnd)).executeTakeFirst(),currentInitialValue=Number(currentPeriodValues?.total_initial||0),currentBalance=Number(currentPeriodValues?.total_balance||0),currentCardCount=Number(currentPeriodValues?.card_count||0),previousInitialValue=Number(previousPeriodValues?.total_initial||0),previousBalance=Number(previousPeriodValues?.total_balance||0),previousCardCount=Number(previousPeriodValues?.card_count||0),currentUtilizationRate=currentInitialValue>0?(currentInitialValue-currentBalance)/currentInitialValue*100:0,previousUtilizationRate=previousInitialValue>0?(previousInitialValue-previousBalance)/previousInitialValue*100:0,currentAverageInitial=currentCardCount>0?currentInitialValue/currentCardCount:0,currentAverageBalance=currentCardCount>0?currentBalance/currentCardCount:0,initialValueDifference=currentInitialValue-previousInitialValue,balanceDifference=currentBalance-previousBalance,cardCountDifference=currentCardCount-previousCardCount,initialValuePercentageChange=previousInitialValue!==0?initialValueDifference/previousInitialValue*100:currentInitialValue>0?100:0,balancePercentageChange=previousBalance!==0?balanceDifference/previousBalance*100:currentBalance>0?100:0,cardCountPercentageChange=previousCardCount!==0?cardCountDifference/previousCardCount*100:currentCardCount>0?100:0;return{current_period:{total_initial_value:currentInitialValue,total_remaining_balance:currentBalance,utilization_rate:currentUtilizationRate,card_count:currentCardCount,average_initial_value:currentAverageInitial,average_remaining_balance:currentAverageBalance},previous_period:{total_initial_value:previousInitialValue,total_remaining_balance:previousBalance,utilization_rate:previousUtilizationRate,card_count:previousCardCount},comparison:{initial_value:{difference:initialValueDifference,percentage:Math.abs(initialValuePercentageChange),is_increase:initialValueDifference>=0},balance:{difference:balanceDifference,percentage:Math.abs(balancePercentageChange),is_increase:balanceDifference>=0},card_count:{difference:cardCountDifference,percentage:Math.abs(cardCountPercentageChange),is_increase:cardCountDifference>=0}},days_range:_daysRange}}export async function fetchTotalValue(){const{total_value}=await aggregateStats("gift_cards",{total_value:{kind:"sum",column:"initial_balance"}});return{total_value}}export async function fetchTotalCurrentBalance(){const{total_balance}=await aggregateStats("gift_cards",{total_balance:{kind:"sum",column:"current_balance"}});return{total_balance}}
@@ -0,0 +1 @@
1
+ export{bulkDestroy,deactivate,destroy,destroyExpired}from"./destroy";export{calculateGiftCardValues,checkBalance,compareActiveGiftCards,fetchActive,fetchAll,fetchByCode,fetchById,fetchStats,fetchTotalCurrentBalance,fetchTotalValue}from"./fetch";export{store}from"./store";export{update,updateBalance}from"./update";
@@ -0,0 +1 @@
1
+ import{randomUUIDv7}from"bun";import{db}from"@stacksjs/database/runtime";import{asModelRow}from"../utils/model-row";import{HttpError}from"@stacksjs/error-handling";import{isUniqueViolation}from"@stacksjs/orm";import{insertedId}from"../utils/inserted-id";export async function store(data){const d=data,giftCardData={...data,current_balance:d.current_balance??d.initial_balance,status:data.status||"ACTIVE",is_active:d.is_active??!0,uuid:randomUUIDv7()};try{const createdGiftCard=await db.insertInto("gift_cards").values(giftCardData).executeTakeFirst(),id=insertedId(createdGiftCard);if(id!==void 0){const giftCard=await db.selectFrom("gift_cards").where("id","=",id).selectAll().executeTakeFirst();return asModelRow(giftCard)}const giftCard=await db.selectFrom("gift_cards").where("uuid","=",giftCardData.uuid).selectAll().executeTakeFirst();return asModelRow(giftCard,!0)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A gift card with this code already exists");if(error instanceof Error)throw Error(`Failed to create gift card: ${error.message}`);throw error}}
@@ -0,0 +1,10 @@
1
+ import{db,mutationCount,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{env}from"@stacksjs/env";import{HttpError}from"@stacksjs/error-handling";import{formatDate,isUniqueViolation}from"@stacksjs/orm";import{fetchById}from"./fetch";export async function update(id,data){if(!await fetchById(id))return;try{await db.updateTable("gift_cards").set({...data,updated_at:formatDate(new Date)}).where("id","=",id).execute();return await fetchById(id)}catch(error){if(error instanceof HttpError)throw error;if(isUniqueViolation(error))throw new HttpError(409,"A gift card with this code already exists");if(error instanceof Error)throw Error(`Failed to update gift card: ${error.message}`);throw error}}export async function updateBalance(id,amount){if(!Number.isFinite(amount))throw Error(`Gift card balance adjustment must be a finite number, got ${amount}`);const dialect=sqlHelpers(env.DB_CONNECTION||"sqlite"),p=dialect.param,now=formatDate(new Date),isRedemption=amount<0,isReload=amount>0,statusPredicate=isReload?`(status = 'ACTIVE' OR (status = 'USED' AND is_reloadable = ${dialect.boolTrue}))`:"status = 'ACTIVE'",values=[],bind=(value)=>{values.push(value);return p(values.length)},statement=await db.unsafe(`UPDATE gift_cards
2
+ SET status = CASE WHEN current_balance + ${bind(amount)} = 0 THEN 'USED' WHEN 1 = 0 THEN status ELSE 'ACTIVE' END,
3
+ current_balance = current_balance + ${bind(amount)},
4
+ ${isRedemption?`last_used_date = ${bind(now)},
5
+ `:""}updated_at = ${bind(now)}
6
+ WHERE id = ${bind(id)}
7
+ AND is_active = ${dialect.boolTrue}
8
+ AND ${statusPredicate}
9
+ AND (expiry_date IS NULL OR expiry_date >= ${bind(now)})
10
+ AND current_balance + ${bind(amount)} >= 0`,values);if(mutationCount(statement)>0)return await fetchById(id);const existing=await fetchById(id);if(!existing)throw Error(`Gift card with ID ${id} not found`);const row=existing;if(!row.is_active&&!row.isActive)throw Error("Gift card is not active");if(existing.status==="USED"&&isReload&&!(row.is_reloadable??row.isReloadable))throw Error("Gift card is not reloadable");if(existing.status!=="ACTIVE")throw Error("Gift card is not active");const expiry=parseSqlDateTime(row.expiry_date??row.expiryDate),checkedAt=parseSqlDateTime(now);if(expiry&&checkedAt&&expiry.getTime()<checkedAt.getTime())throw Error("Gift card has expired");if(amount===0&&Number(row.current_balance??row.currentBalance)>=0)return existing;throw Error("Insufficient gift card balance")}
@@ -0,0 +1 @@
1
+ import{decimalToMinor,numberToMinor,PriceFormatError}from"./money";import{cleanName,optionalString}from"./text";const EMAIL=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export function normalizeEmail(input){if(typeof input!=="string")return null;const email=input.trim().toLowerCase();return EMAIL.test(email)?email:null}export function isoTimestamp(input){if(typeof input!=="string"||input.trim()==="")return null;const at=new Date(input.trim());return Number.isNaN(at.getTime())?null:at.toISOString()}export function gmtTimestamp(input){if(typeof input!=="string"||input.trim()==="")return null;const raw=input.trim();return isoTimestamp(/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)?raw:`${raw}Z`)}export function personName(...parts){const name=parts.map((part)=>cleanName(part)).filter(Boolean).join(" ");return name===""?null:name}export function phoneOf(...candidates){for(const candidate of candidates){const phone=optionalString(typeof candidate==="number"?String(candidate):candidate);if(phone)return phone.slice(0,50)}return null}export function addressLine(...parts){const line=parts.map((part)=>cleanName(part)).filter(Boolean).join(", ");return line===""?null:line}export function signedMinor(value,exponent){if(value===null||value===void 0||value==="")return null;if(typeof value==="number"){if(!Number.isFinite(value))throw new PriceFormatError(value,"expected a finite number");const magnitude=numberToMinor(Math.abs(value),exponent);return value<0?-magnitude:magnitude}if(typeof value!=="string")throw new PriceFormatError(value,"expected a decimal amount");const raw=value.trim(),negative=raw.startsWith("-"),magnitude=decimalToMinor(negative||raw.startsWith("+")?raw.slice(1):raw,exponent);if(magnitude===null)return null;return negative?-magnitude:magnitude}export function amountOrZero(value,exponent,label,externalId,warnings,fallback=0){let minor;try{minor=signedMinor(value,exponent)}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; recorded as ${fallback}`});return fallback}if(minor===null)return fallback;if(minor<0){warnings.push({externalId,message:`${label} is negative (${String(value)}); recorded as ${fallback}`});return fallback}return minor}export function unitFromTotal(totalMinor,quantity){if(quantity<=1)return totalMinor;return Math.floor((2*totalMinor+quantity)/(2*quantity))}export function quantityOf(value){const quantity=typeof value==="string"?Number(value):value;return typeof quantity==="number"&&Number.isInteger(quantity)&&quantity>0?quantity:null}export function currencyCode(value){const code=typeof value==="string"?value.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}
File without changes
@@ -0,0 +1 @@
1
+ import{db,sqlHelpers}from"@stacksjs/database/runtime";import{env}from"@stacksjs/env";import{formatDate}from"@stacksjs/orm";import{DEFAULT_CUSTOMER_AVATAR}from"../customers/store";import{normalizeStoreUrl,storeHost}from"./http";import{catalogUuid}from"./identity";import{formatMinor}from"./money";const NAME_MAX=255;export function customerUuid(source,host,externalId,email){return catalogUuid(source,host,"customer",externalId??`email:${email}`)}export function promotedCustomerUuid(source,host,existingUuid,externalId,email){if(!externalId)return null;if(existingUuid!==null&&existingUuid!==customerUuid(source,host,null,email))return null;return customerUuid(source,host,externalId,email)}function stamp(iso){return iso?formatDate(new Date(iso)):null}export function customerRow(customer,uuid,now,mode){const row={name:customer.name.slice(0,NAME_MAX),updated_at:now};if(mode==="create"){row.uuid=uuid;row.email=customer.email;row.phone=customer.phone;row.total_spent=customer.totalSpentMinor??0;row.last_order=stamp(customer.lastOrderAt);row.status=customer.active===!1?"Inactive":"Active";row.avatar=customer.avatarUrl??DEFAULT_CUSTOMER_AVATAR;row.created_at=stamp(customer.createdAt)??now;return row}if(customer.phone!==null)row.phone=customer.phone;if(customer.totalSpentMinor!==null)row.total_spent=customer.totalSpentMinor;if(customer.lastOrderAt!==null)row.last_order=stamp(customer.lastOrderAt);if(customer.active!==null)row.status=customer.active?"Active":"Inactive";return row}export function lineLabel(line){return line.sku?`${line.name} (SKU ${line.sku})`:line.name}export function orderRow(order,host,customerId,now){const row={uuid:catalogUuid(order.source,host,"order",order.externalId),status:order.status,total_amount:order.totalMinor,currency:order.currency,tax_amount:order.taxMinor,discount_amount:order.discountMinor,delivery_fee:order.shippingMinor,tip_amount:order.tipMinor,order_type:order.shippingAddress?"DELIVERY":"TAKEOUT",delivery_address:order.shippingAddress,special_instructions:order.note,created_at:stamp(order.placedAt)??now,updated_at:now};if(customerId!==null)row.customer_id=customerId;return row}class CustomerIndex{byUuid=new Map;byEmail=new Map;uuidById=new Map;async load(repository,keys){const uuids=[...new Set(keys.map((key)=>key.uuid).filter((uuid)=>uuid!==null&&!this.byUuid.has(uuid)))],emails=[...new Set(keys.map((key)=>key.email).filter((email)=>email!==null&&!this.byEmail.has(email)))];if(uuids.length===0&&emails.length===0)return;for(const row of await repository.findCustomers({uuids,emails}))this.remember(row.id,row.uuid,row.email)}remember(id,uuid,email){if(uuid){const previous=this.uuidById.get(id);if(previous&&previous!==uuid)this.byUuid.delete(previous);this.byUuid.set(uuid,id);this.uuidById.set(id,uuid)}if(email)this.byEmail.set(email.trim().toLowerCase(),id)}find(uuid,email){const byUuid=uuid?this.byUuid.get(uuid):void 0;if(byUuid!==void 0)return{id:byUuid,matchedBy:"uuid"};const byEmail=email?this.byEmail.get(email):void 0;return byEmail!==void 0?{id:byEmail,matchedBy:"email"}:null}uuidOf(id){return this.uuidById.get(id)??null}emailTakenByOther(email,id){const holder=this.byEmail.get(email);return holder!==void 0&&holder!==id}}function base(options){const storeUrl=normalizeStoreUrl(options.storeUrl);return{storeUrl,host:storeHost(storeUrl),clock:options.now??(()=>new Date),fetchOptions:{credentials:options.credentials,limit:options.limit,fetch:options.fetch,currency:options.currency,adminUrl:options.adminUrl,sleep:options.sleep}}}export async function importCustomers(options){const{storeUrl,host,clock,fetchOptions}=base(options),{adapter,repository}=options,index=new CustomerIndex,seenEmails=new Set,result={source:adapter.name,storeUrl,dryRun:options.dryRun===!0,pages:[],customers:[],counts:{customers:{created:0,updated:0},merged:0},warnings:[]};async function write(customer,repo){const now=formatDate(clock()),uuid=customerUuid(customer.source,host,customer.externalId,customer.email),found=index.find(uuid,customer.email);if(!found){const id=await repo.createCustomer(customerRow(customer,uuid,now,"create"));index.remember(id,uuid,customer.email);return{action:"create",externalId:customer.externalId,email:customer.email,name:customer.name,matchedBy:null}}const row=customerRow(customer,uuid,now,"update");if(found.matchedBy==="uuid")if(index.emailTakenByOther(customer.email,found.id))result.warnings.push({externalId:customer.externalId??void 0,message:`email changed to ${customer.email}, which another customer already has; kept the old address`});else row.email=customer.email;const promoted=found.matchedBy==="email"?promotedCustomerUuid(customer.source,host,index.uuidOf(found.id),customer.externalId,customer.email):null;if(promoted)row.uuid=promoted;await repo.updateCustomer(found.id,row);index.remember(found.id,promoted,customer.email);return{action:"update",externalId:customer.externalId,email:customer.email,name:customer.name,matchedBy:found.matchedBy}}for await(const page of adapter.customers(storeUrl,fetchOptions)){result.pages.push(page.url);result.warnings.push(...page.warnings);const fresh=[];for(const customer of page.items){if(seenEmails.has(customer.email)){result.counts.merged++;continue}seenEmails.add(customer.email);fresh.push(customer)}await index.load(repository,fresh.map((customer)=>({uuid:customerUuid(customer.source,host,customer.externalId,customer.email),email:customer.email})));for(const customer of fresh){const imported=await write(customer,repository);result.counts.customers[imported.action==="create"?"created":"updated"]++;result.customers.push(imported);options.onCustomer?.(imported)}}if(result.counts.merged>0)result.warnings.push({message:`${result.counts.merged} source customer${result.counts.merged===1?"":"s"} shared an email with another and ${result.counts.merged===1?"was":"were"} merged into one Stacks customer`});return result}export async function importOrders(options){const{storeUrl,host,clock,fetchOptions}=base(options),{adapter,repository}=options,index=new CustomerIndex,seenOrders=new Set,currencies=new Set,unlinkedExamples=new Set,refundExamples=[],result={source:adapter.name,storeUrl,dryRun:options.dryRun===!0,pages:[],orders:[],counts:{orders:{created:0,updated:0},customers:{created:0,updated:0},lines:{linked:0,unlinked:0,productless:0},duplicates:0,partlyRefunded:0},currencies:[],warnings:[]},productUuid=(order,id)=>catalogUuid(order.source,host,"product",id),variantUuid=(order,id)=>catalogUuid(order.source,host,"variant",id);function orderCustomerUuid(order){const customer=order.customer;if(customer?.externalId)return customerUuid(order.source,host,customer.externalId,customer.email??"");return customer?.email?customerUuid(order.source,host,null,customer.email):null}async function customerFor(order,repo,now){const customer=order.customer;if(!customer)return null;const uuid=orderCustomerUuid(order),found=index.find(uuid,customer.email);if(found){const promoted=found.matchedBy==="email"&&customer.email?promotedCustomerUuid(order.source,host,index.uuidOf(found.id),customer.externalId,customer.email):null;if(promoted){await repo.updateCustomer(found.id,{uuid:promoted,updated_at:now});index.remember(found.id,promoted,customer.email)}return found.id}if(!customer.email){result.warnings.push({externalId:order.externalId,message:`order ${order.number??order.externalId} has no customer email; imported without a customer`});return null}const id=await repo.createCustomer(customerRow({source:order.source,externalId:customer.externalId,email:customer.email,name:customer.name??customer.email,phone:customer.phone,active:null,totalSpentMinor:null,currency:null,lastOrderAt:order.placedAt,avatarUrl:null,createdAt:order.placedAt},uuid,now,"create"));index.remember(id,uuid,customer.email);result.counts.customers.created++;return id}async function write(order,repo,known){const now=formatDate(clock()),customerId=await customerFor(order,repo,now),row=orderRow(order,host,customerId,now),existing=known.orders.get(row.uuid);let orderId;if(existing===void 0)orderId=await repo.createOrder(row);else{orderId=existing;const changes={...row};delete changes.uuid;await repo.updateOrder(orderId,changes)}let unlinked=0,productless=0;const items=order.lines.map((line)=>{const productId=(line.productExternalId?known.products.get(productUuid(order,line.productExternalId)):void 0)??(line.variantExternalId?known.variants.get(variantUuid(order,line.variantExternalId)):void 0)??null;if(productId===null)if(!line.productExternalId&&!line.variantExternalId)productless++;else{unlinked++;if(unlinkedExamples.size<5)unlinkedExamples.add(lineLabel(line))}return{order_id:orderId,product_id:productId,quantity:line.quantity,price:line.unitPriceMinor,special_instructions:lineLabel(line),created_at:now,updated_at:now}});await repo.replaceOrderItems(orderId,items);return{action:existing===void 0?"create":"update",externalId:order.externalId,number:order.number,status:order.status,sourceStatus:order.sourceStatus,totalMinor:order.totalMinor,currency:order.currency,lines:items.length,unlinkedLines:unlinked,productlessLines:productless,refundedMinor:order.refundedMinor??0,customerEmail:order.customer?.email??null,customerId}}for await(const page of adapter.orders(storeUrl,fetchOptions)){result.pages.push(page.url);result.warnings.push(...page.warnings);const fresh=page.items.filter((order)=>{if(!seenOrders.has(order.externalId)){seenOrders.add(order.externalId);return!0}result.counts.duplicates++;return!1}),orderUuids=fresh.map((order)=>catalogUuid(order.source,host,"order",order.externalId)),productUuids=[...new Set(fresh.flatMap((order)=>order.lines.flatMap((line)=>line.productExternalId?[productUuid(order,line.productExternalId)]:[])))],variantUuids=[...new Set(fresh.flatMap((order)=>order.lines.flatMap((line)=>line.variantExternalId?[variantUuid(order,line.variantExternalId)]:[])))],known={orders:orderUuids.length>0?await repository.findOrders(orderUuids):new Map,products:productUuids.length>0?await repository.findProducts(productUuids):new Map,variants:variantUuids.length>0?await repository.findVariantProducts(variantUuids):new Map};await index.load(repository,fresh.map((order)=>({uuid:orderCustomerUuid(order),email:order.customer?.email??null})));const latest=new Map;for(const order of fresh){currencies.add(order.currency);const{customerId,...imported}=repository.transaction?await repository.transaction((repo)=>write(order,repo,known)):await write(order,repository,known);result.counts.orders[imported.action==="create"?"created":"updated"]++;result.counts.lines.linked+=imported.lines-imported.unlinkedLines-imported.productlessLines;result.counts.lines.unlinked+=imported.unlinkedLines;result.counts.lines.productless+=imported.productlessLines;if(imported.refundedMinor>0&&imported.status!=="REFUNDED"){result.counts.partlyRefunded++;if(refundExamples.length<3)refundExamples.push(`${imported.number??imported.externalId}: ${formatMinor(imported.refundedMinor,imported.currency)} of ${formatMinor(imported.totalMinor,imported.currency)}`)}const placed=stamp(order.placedAt);if(customerId!==null&&placed&&placed>(latest.get(customerId)??""))latest.set(customerId,placed);result.orders.push(imported);options.onOrder?.(imported)}if(repository.advanceLastOrder)for(const[customerId,at]of latest)await repository.advanceLastOrder(customerId,at)}result.currencies=[...currencies].sort();if(result.counts.lines.unlinked>0){const examples=[...unlinkedExamples].map((label)=>`"${label}"`).join(", "),one=result.counts.lines.unlinked===1;result.warnings.push({message:`${result.counts.lines.unlinked} order line${one?" references a product":"s reference products"} not in Stacks (e.g. ${examples}); kept with ${one?"its":"their"} label and price but no product link. Import the catalog (buddy commerce:import <url> --from ${adapter.name}), then re-run the order import to link ${one?"it":"them"}.`})}if(result.counts.partlyRefunded>0){const one=result.counts.partlyRefunded===1;result.warnings.push({message:`${result.counts.partlyRefunded} order${one?" was":"s were"} partly refunded at the source (${refundExamples.join("; ")}${one?"":", ..."}). Stacks orders have no refund column, so total_amount is what was charged.`})}return result}export function createDatabaseAccountRepository(connection=db){async function idByUuid(table,uuid){const row=await connection.selectFrom(table).where("uuid","=",uuid).select("id").executeTakeFirst();if(!row)throw Error(`Inserted a row into ${table} but could not read it back (uuid ${uuid})`);return Number(row.id)}async function chunked(values,read){const rows=[];for(let start=0;start<values.length;start+=500)rows.push(...await read(values.slice(start,start+500)));return rows}const repository={async findCustomers({uuids,emails}){const byUuid=await chunked(uuids,(chunk)=>connection.selectFrom("customers").where("uuid","in",chunk).select(["id","uuid","email"]).execute()),param=sqlHelpers(env.DB_CONNECTION||"sqlite").param,byEmail=await chunked(emails.map((email)=>email.trim().toLowerCase()),async(chunk)=>{const placeholders=chunk.map((_,index)=>param(index+1)).join(", "),rows=await connection.unsafe(`SELECT id, uuid, email FROM customers WHERE LOWER(email) IN (${placeholders})`,chunk);return Array.isArray(rows)?rows:[]});return[...byUuid,...byEmail].map((row)=>({id:Number(row.id),uuid:row.uuid??null,email:String(row.email)}))},async createCustomer(row){await connection.insertInto("customers").values(row).execute();return idByUuid("customers",row.uuid)},async updateCustomer(id,row){await connection.updateTable("customers").set(row).where("id","=",id).execute()},async advanceLastOrder(id,at){const param=sqlHelpers(env.DB_CONNECTION||"sqlite").param;await connection.unsafe(`UPDATE customers SET last_order = ${param(1)} WHERE id = ${param(2)} AND (last_order IS NULL OR last_order < ${param(3)})`,[at,id,at])},async findOrders(uuids){const rows=await chunked(uuids,(chunk)=>connection.selectFrom("orders").where("uuid","in",chunk).select(["id","uuid"]).execute());return new Map(rows.map((row)=>[String(row.uuid),Number(row.id)]))},async createOrder(row){await connection.insertInto("orders").values(row).execute();return idByUuid("orders",row.uuid)},async updateOrder(id,row){await connection.updateTable("orders").set(row).where("id","=",id).execute()},async replaceOrderItems(orderId,rows){await connection.deleteFrom("order_items").where("order_id","=",orderId).execute();if(rows.length>0)await connection.insertInto("order_items").values(rows).execute()},async findProducts(uuids){const rows=await chunked(uuids,(chunk)=>connection.selectFrom("products").where("uuid","in",chunk).select(["id","uuid"]).execute());return new Map(rows.map((row)=>[String(row.uuid),Number(row.id)]))},async findVariantProducts(uuids){const rows=await chunked(uuids,(chunk)=>connection.selectFrom("product_variants").where("uuid","in",chunk).select(["uuid","product_id"]).execute());return new Map(rows.filter((row)=>row.product_id!==null&&row.product_id!==void 0).map((row)=>[String(row.uuid),Number(row.product_id)]))}};if(connection===db)repository.transaction=(work)=>db.transaction((trx)=>work(createDatabaseAccountRepository(trx)));return repository}export function createMemoryAccountRepository(shared={tables:{}}){const tables=shared.tables;for(const table of["customers","orders","order_items","products","product_variants"])tables[table]??=[];function insert(table,row){const rows=tables[table],id=rows.reduce((max,entry)=>Math.max(max,Number(entry.id)),0)+1;rows.push({id,...row});return id}function update(table,id,row){const target=tables[table].find((entry)=>entry.id===id);if(target)Object.assign(target,row)}const byUuid=(table,uuids)=>new Map(tables[table].filter((row)=>uuids.includes(row.uuid)).map((row)=>[row.uuid,row.id]));return{tables,findCustomers:async({uuids,emails})=>tables.customers.filter((row)=>uuids.includes(row.uuid)||emails.includes(String(row.email).trim().toLowerCase())).map((row)=>({id:row.id,uuid:row.uuid??null,email:row.email})),createCustomer:async(row)=>{if(tables.customers.some((existing)=>existing.email===row.email))throw Error(`UNIQUE constraint failed: customers.email (${row.email})`);return insert("customers",row)},updateCustomer:async(id,row)=>update("customers",id,row),advanceLastOrder:async(id,at)=>{const row=tables.customers.find((entry)=>entry.id===id);if(row&&(row.last_order===null||row.last_order===void 0||String(row.last_order)<at))row.last_order=at},findOrders:async(uuids)=>byUuid("orders",uuids),createOrder:async(row)=>insert("orders",row),updateOrder:async(id,row)=>update("orders",id,row),replaceOrderItems:async(orderId,rows)=>{tables.order_items=tables.order_items.filter((row)=>row.order_id!==orderId);for(const row of rows)insert("order_items",row)},findProducts:async(uuids)=>byUuid("products",uuids),findVariantProducts:async(uuids)=>new Map(tables.product_variants.filter((row)=>uuids.includes(row.uuid)).map((row)=>[row.uuid,row.product_id]))}}export function createReadOnlyAccountRepository(inner){let nextId=-1;const fake=async()=>nextId--,pretend=[];return{async findCustomers(query){const found=await inner.findCustomers(query),extra=pretend.filter((row)=>row.uuid!==null&&query.uuids.includes(row.uuid)||query.emails.includes(row.email));return[...found,...extra]},findOrders:inner.findOrders,findProducts:inner.findProducts,findVariantProducts:inner.findVariantProducts,async createCustomer(row){const id=nextId--;pretend.push({id,uuid:row.uuid??null,email:String(row.email??"").toLowerCase()});return id},createOrder:fake,updateCustomer:async()=>{},advanceLastOrder:async()=>{},updateOrder:async()=>{},replaceOrderItems:async()=>{}}}
@@ -0,0 +1 @@
1
+ import{CatalogFetchError,fetchJson}from"./http";import{redactSecrets}from"./credentials";export const MAX_RETRIES=5;const DEFAULT_RETRY_SECONDS=2,MAX_RETRY_SECONDS=60;export const defaultSleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));export class AdminCredentialError extends CatalogFetchError{constructor(url,message,status){super(url,message,status);this.name="AdminCredentialError"}}function scrub(error,secrets,suffix=""){const message=redactSecrets(`${error.message}${suffix}`,secrets),body=error.body===void 0?void 0:redactSecrets(error.body,secrets);return new CatalogFetchError(redactSecrets(error.url,secrets),message,error.status,body,error.retryAfter)}export async function adminRequest(url,options){const{fetch:fetcher,secrets,credentialHelp,hint="",sleep,...request}=options,wait=sleep??defaultSleep;for(let attempt=0;;attempt++)try{return await fetchJson(url,fetcher,"",request)}catch(error){if(!(error instanceof CatalogFetchError))throw error;if(error.status===429&&attempt<MAX_RETRIES){const seconds=error.retryAfter!==void 0&&error.retryAfter>=0?error.retryAfter:DEFAULT_RETRY_SECONDS;await wait(Math.min(seconds,MAX_RETRY_SECONDS)*1000);continue}if(error.status===401||error.status===403){const reason=error.status===401?"The credentials were rejected.":"The credentials were accepted but lack permission to read this.";throw new AdminCredentialError(redactSecrets(url,secrets),redactSecrets(`${url} responded ${error.status}. ${reason} ${credentialHelp}`,secrets),error.status)}if(error.status===429)throw scrub(error,secrets,` Still rate limited after ${MAX_RETRIES} retries; re-run later, the import resumes safely.`);throw scrub(error,secrets,hint?` ${hint}`:"")}}
@@ -0,0 +1 @@
1
+ export const ACCOUNT_CREDENTIAL_ENV={shopify:["SHOPIFY_ADMIN_TOKEN"],woocommerce:["WOOCOMMERCE_CONSUMER_KEY","WOOCOMMERCE_CONSUMER_SECRET"],shopware:["SHOPWARE_CLIENT_ID","SHOPWARE_CLIENT_SECRET"]},ACCOUNT_CREDENTIAL_HELP={shopify:"Create a custom app for the store (Shopify admin > Settings > Apps > Develop apps, or the Shopify Dev Dashboard), give it the Admin API scopes read_customers and read_orders (plus read_all_orders for orders older than 60 days), install it, and copy its Admin API access token (shpat_...).",woocommerce:"Create a key in WordPress admin > WooCommerce > Settings > Advanced > REST API > Add key, with Read permission, for a user who can manage WooCommerce. The key starts with ck_ and the secret with cs_.",shopware:"Create an integration in the Shopware Administration > Settings > System > Integrations, with a role that can read customers and orders. Its access key id (SWIA...) is the client id and its secret access key is the client secret."};export class MissingCredentialsError extends Error{constructor(message){super(message);this.name="MissingCredentialsError"}}function read(env,name){const value=env[name]?.trim();return value?value:void 0}export function readAccountCredentials(source,env){const key=source.trim().toLowerCase(),names=ACCOUNT_CREDENTIAL_ENV[key];if(!names)throw new MissingCredentialsError(`Customers and orders can be imported from ${Object.keys(ACCOUNT_CREDENTIAL_ENV).join(", ")}; got "${source}".`);const missing=names.filter((name)=>!read(env,name));if(missing.length>0){const what=missing.length===1?`${missing[0]} is not set`:`${missing.join(" and ")} are not set`;throw new MissingCredentialsError(`Importing customers or orders from ${key} needs Admin API credentials in the environment: ${what}. They are read from env (or .env) only, never from a flag, so they stay out of shell history. ${ACCOUNT_CREDENTIAL_HELP[key]}`)}const values=names.map((name)=>read(env,name));for(const[index,value]of values.entries())if(/\s/.test(value))throw new MissingCredentialsError(`${names[index]} contains whitespace; paste the credential exactly as issued. ${ACCOUNT_CREDENTIAL_HELP[key]}`);if(key==="shopify")return{source:"shopify",accessToken:values[0]};if(key==="woocommerce")return{source:"woocommerce",consumerKey:values[0],consumerSecret:values[1]};if(/^SWSC/i.test(values[0]))throw new MissingCredentialsError(`SHOPWARE_CLIENT_ID looks like a sales channel access key (SWSC...), which only reads the public catalog. ${ACCOUNT_CREDENTIAL_HELP.shopware}`);return{source:"shopware",clientId:values[0],clientSecret:values[1]}}export function credentialSecrets(credentials){switch(credentials.source){case"shopify":return[credentials.accessToken];case"woocommerce":return[credentials.consumerKey,credentials.consumerSecret];case"shopware":return[credentials.clientId,credentials.clientSecret]}}export function redactSecrets(text,secrets){let result=text;for(const secret of secrets)if(secret.length>=4)result=result.split(secret).join("[redacted]");return result}
@@ -0,0 +1 @@
1
+ export class CatalogFetchError extends Error{url;status;body;retryAfter;constructor(url,message,status,body,retryAfter){super(message);this.url=url;this.status=status;this.body=body;this.retryAfter=retryAfter;this.name="CatalogFetchError"}}const ERROR_BODY_MAX=2000,DEFAULT_TIMEOUT_MS=30000;export function normalizeStoreUrl(input){const raw=input.trim();if(!raw)throw TypeError("A store URL is required, e.g. https://shop.example.com");const withScheme=/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)?raw:`https://${raw}`;let url;try{url=new URL(withScheme)}catch{throw TypeError(`"${input}" is not a valid store URL`)}if(url.protocol!=="https:"&&url.protocol!=="http:")throw TypeError(`"${input}" must be an http(s) URL`);const path=url.pathname.replace(/\/+$/,"");return`${url.protocol}//${url.host.toLowerCase()}${path}`}export function storeHost(storeUrl){return new URL(storeUrl).host.toLowerCase()}export function retryAfterSeconds(header,now=Date.now){const raw=header?.trim();if(!raw)return;if(/^\d+(?:\.\d+)?$/.test(raw))return Number(raw);const at=Date.parse(raw);return Number.isNaN(at)?void 0:Math.max(0,(at-now())/1000)}export async function fetchJson(url,fetcher=fetch,hint="",request={}){const suffix=hint?` ${hint}`:"",headers={accept:"application/json",...request.headers};if(request.json!==void 0)headers["content-type"]="application/json";let response;try{response=await fetcher(url,{method:request.method??(request.json!==void 0?"POST":"GET"),headers,...request.json!==void 0?{body:JSON.stringify(request.json)}:{},signal:AbortSignal.timeout(DEFAULT_TIMEOUT_MS)})}catch(error){const reason=error instanceof Error?error.message:String(error);throw new CatalogFetchError(url,`Could not reach ${url}: ${reason}.${suffix}`)}if(!response.ok){const status=`${response.status}${response.statusText?` ${response.statusText}`:""}`,body=await response.text().catch(()=>"");throw new CatalogFetchError(url,`${url} responded ${status}.${suffix}`,response.status,body?body.slice(0,ERROR_BODY_MAX):void 0,retryAfterSeconds(response.headers.get("retry-after")))}const body=await response.text();try{return{data:JSON.parse(body),headers:response.headers}}catch{const type=response.headers.get("content-type")||"no content-type";throw new CatalogFetchError(url,`${url} did not return JSON (${type}).${suffix}`,response.status)}}
@@ -0,0 +1 @@
1
+ import{createHash}from"node:crypto";export const CATALOG_IMPORT_NAMESPACE="5d0c8a3e-6f1b-4c2a-9e47-b18f3d2a6c90";export function uuidV5(name,namespace=CATALOG_IMPORT_NAMESPACE){const namespaceHex=namespace.replace(/-/g,"");if(!/^[0-9a-f]{32}$/i.test(namespaceHex))throw TypeError(`Invalid namespace UUID "${namespace}"`);const bytes=createHash("sha1").update(Buffer.from(namespaceHex,"hex")).update(name,"utf8").digest().subarray(0,16);bytes[6]=bytes[6]&15|80;bytes[8]=bytes[8]&63|128;const hex=bytes.toString("hex");return`${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`}export function catalogUuid(source,host,kind,externalId){return uuidV5(`${source}:${host.toLowerCase()}:${kind}:${externalId}`)}
@@ -0,0 +1 @@
1
+ import{shopifyAdapter}from"./shopify";import{shopifyAccountAdapter}from"./shopify-admin";import{shopwareAdapter}from"./shopware";import{shopwareAccountAdapter}from"./shopware-admin";import{wooCommerceAdapter}from"./woocommerce";import{wooCommerceAccountAdapter}from"./woocommerce-admin";export const catalogAdapters={shopify:shopifyAdapter,woocommerce:wooCommerceAdapter,shopware:shopwareAdapter};export function catalogAdapter(name){return catalogAdapters[name.trim().toLowerCase()]}export const accountAdapters={shopify:shopifyAccountAdapter,woocommerce:wooCommerceAccountAdapter,shopware:shopwareAccountAdapter};export function accountAdapter(name){return accountAdapters[name.trim().toLowerCase()]}export*from"./account-mapping";export*from"./account-writer";export*from"./admin-http";export*from"./credentials";export*from"./http";export*from"./identity";export*from"./money";export*from"./shopify";export*from"./shopify-admin";export*from"./shopware";export*from"./shopware-admin";export*from"./text";export*from"./woocommerce";export*from"./woocommerce-admin";export*from"./writer";
@@ -0,0 +1 @@
1
+ import{decimalToMinor,PriceFormatError}from"../money";export{currencyExponent,decimalToMinor,formatMinor,minorToDecimal,PriceFormatError,rescaleMinor}from"../money";export function numberToDecimalString(value){if(typeof value!=="number"||!Number.isFinite(value))throw new PriceFormatError(value,"expected a finite number");if(value<0)throw new PriceFormatError(value,"expected an amount of zero or more");const text=String(value),match=/^(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);if(!match)return text;const[,whole,fraction="",exponent]=match,digits=`${whole}${fraction}`,point=whole.length+Number(exponent);if(point<=0)return`0.${"0".repeat(-point)}${digits}`;if(point>=digits.length)return`${digits}${"0".repeat(point-digits.length)}`;return`${digits.slice(0,point)}.${digits.slice(point)}`}export function numberToMinor(value,exponent=2){if(value===null||value===void 0)return null;if(typeof value==="string")return decimalToMinor(value,exponent);return decimalToMinor(numberToDecimalString(value),exponent)}
@@ -0,0 +1 @@
1
+ import{addressLine,amountOrZero,currencyCode,isoTimestamp,normalizeEmail,personName,phoneOf,quantityOf,signedMinor}from"./account-mapping";import{adminRequest}from"./admin-http";import{ACCOUNT_CREDENTIAL_HELP,credentialSecrets}from"./credentials";import{CatalogFetchError,normalizeStoreUrl}from"./http";import{currencyExponent,PriceFormatError}from"./money";import{cleanName,optionalString}from"./text";export const SHOPIFY_ADMIN_API_VERSION="2026-10";const PAGE_SIZE=250,MAX_PAGES=4000,CUSTOMER_FIELDS=["id","email","first_name","last_name","phone","state","total_spent","currency","created_at","default_address"],ORDER_FIELDS=["id","name","order_number","email","contact_email","created_at","processed_at","cancelled_at","currency","total_price","total_tax","total_discounts","total_shipping_price_set","shipping_lines","total_tip_received","financial_status","fulfillment_status","customer","billing_address","shipping_address","line_items","note"],CREDENTIAL_HELP=`Check SHOPIFY_ADMIN_TOKEN, and that the app is installed on this store with the read_customers and read_orders scopes. ${ACCOUNT_CREDENTIAL_HELP.shopify}`;export function mapShopifyCustomer(raw){const externalId=raw?.id===void 0||raw.id===null?"":String(raw.id),warnings=[],email=normalizeEmail(raw?.email);if(!externalId||!email){warnings.push({externalId:externalId||void 0,message:"skipped: the customer has no valid email address, which Stacks customers are keyed by"});return{record:null,warnings}}const currency=currencyCode(raw.currency);let totalSpentMinor=null;try{totalSpentMinor=signedMinor(raw.total_spent,currencyExponent(currency))}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`total_spent: ${error.message}; not imported`})}return{record:{source:"shopify",externalId,email,name:personName(raw.first_name,raw.last_name)??personName(raw.default_address?.first_name,raw.default_address?.last_name)??email,phone:phoneOf(raw.phone,raw.default_address?.phone),active:null,totalSpentMinor:totalSpentMinor!==null&&totalSpentMinor>=0?totalSpentMinor:null,currency,lastOrderAt:null,avatarUrl:null,createdAt:isoTimestamp(raw.created_at)},warnings}}export function shopifyOrderStatus(raw){const financial=(raw.financial_status??"").toLowerCase(),fulfillment=(raw.fulfillment_status??"").toLowerCase();if(financial==="refunded")return{status:"REFUNDED",mapped:!0};if(raw.cancelled_at||financial==="voided"||fulfillment==="restocked")return{status:"CANCELLED",mapped:!0};if(fulfillment==="fulfilled")return{status:"SHIPPED",mapped:!0};if(fulfillment==="partial")return{status:"PROCESSING",mapped:!0};if(["paid","partially_paid","partially_refunded","authorized"].includes(financial))return{status:"PROCESSING",mapped:!0};if(financial==="pending"||financial==="expired"||financial==="")return{status:"PENDING",mapped:!0};return{status:"PENDING",mapped:!1}}function shippingAddressOf(address){if(!address)return null;return addressLine(personName(address.first_name,address.last_name)??address.name,address.address1,address.address2,address.city,[address.province_code??address.province,address.zip].map((part)=>cleanName(part)).filter(Boolean).join(" "),address.country_code??address.country)}export function mapShopifyOrder(raw,context={}){const externalId=raw?.id===void 0||raw.id===null?"":String(raw.id),warnings=[];if(!externalId){warnings.push({message:"skipped: a Shopify order has no id"});return{record:null,warnings}}const currency=currencyCode(raw.currency)??currencyCode(context.currency);if(!currency){warnings.push({externalId,message:"skipped: the order names no currency; pass --currency"});return{record:null,warnings}}const exponent=currencyExponent(currency),amount=(value,label)=>amountOrZero(value,exponent,label,externalId,warnings),shippingSet=raw.total_shipping_price_set?.shop_money?.amount,shippingMinor=shippingSet!==void 0&&shippingSet!==null?amount(shippingSet,"shipping"):(raw.shipping_lines??[]).reduce((sum,line)=>sum+amount(line?.price,"shipping line"),0),lines=[];for(const item of raw.line_items??[]){const lineId=item?.id===void 0||item.id===null?"":String(item.id),quantity=quantityOf(item?.quantity),name=optionalString(item?.name)??personName(item?.title,item?.variant_title)??`Line ${lineId}`;if(!lineId||quantity===null){warnings.push({externalId,message:`line "${name}" has no id or quantity; left out`});continue}lines.push({externalId:lineId,productExternalId:item.product_id===void 0||item.product_id===null?null:String(item.product_id),variantExternalId:item.variant_id===void 0||item.variant_id===null?null:String(item.variant_id),name,sku:optionalString(item.sku),quantity,unitPriceMinor:amount(item.price,`line "${name}" price`)})}const{status,mapped}=shopifyOrderStatus(raw),sourceStatus=[raw.financial_status??"no payment status",raw.fulfillment_status??"unfulfilled",raw.cancelled_at?"cancelled":null].filter(Boolean).join(", ");if(!mapped)warnings.push({externalId,message:`Shopify status "${sourceStatus}" has no Stacks equivalent; imported as ${status}`});const customerEmail=normalizeEmail(raw.customer?.email)??normalizeEmail(raw.email)??normalizeEmail(raw.contact_email),customerId=raw.customer?.id===void 0||raw.customer?.id===null?null:String(raw.customer.id);return{record:{source:"shopify",externalId,number:optionalString(raw.name)??(raw.order_number?`#${raw.order_number}`:null),currency,status,sourceStatus,totalMinor:amount(raw.total_price,"total"),taxMinor:amount(raw.total_tax,"tax"),discountMinor:amount(raw.total_discounts,"discounts"),shippingMinor,tipMinor:amount(raw.total_tip_received,"tip"),customer:customerEmail||customerId?{externalId:customerId,email:customerEmail,name:personName(raw.customer?.first_name,raw.customer?.last_name)??personName(raw.billing_address?.first_name,raw.billing_address?.last_name),phone:phoneOf(raw.customer?.phone,raw.billing_address?.phone)}:null,shippingAddress:shippingAddressOf(raw.shipping_address),note:optionalString(raw.note),placedAt:isoTimestamp(raw.processed_at)??isoTimestamp(raw.created_at),lines},warnings}}export function nextPageInfo(link){if(!link)return null;for(const part of link.split(",")){const match=/<([^>]+)>\s*;\s*rel="?next"?/i.exec(part);if(!match)continue;try{return new URL(match[1]).searchParams.get("page_info")}catch{return null}}return null}export function shopifyAdminBase(storeUrl,adminUrl){const base=normalizeStoreUrl(adminUrl??storeUrl);if(!base.startsWith("https://"))throw TypeError(`${base} is not HTTPS. The Shopify Admin API is HTTPS only, and the access token must never travel in plain text.`);return base}async function*shopifyResource(storeUrl,options,spec){if(options.credentials.source!=="shopify")throw TypeError("The Shopify importer needs Shopify credentials (SHOPIFY_ADMIN_TOKEN).");const base=shopifyAdminBase(storeUrl,options.adminUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,endpoint=`${base}/admin/api/${SHOPIFY_ADMIN_API_VERSION}/${spec.resource}.json`,fields=spec.fields.join(","),request={fetch:fetcher,headers:{"X-Shopify-Access-Token":options.credentials.accessToken},secrets:credentialSecrets(options.credentials),credentialHelp:CREDENTIAL_HELP,hint:`Is ${base} the store's Admin API address? For a custom domain, pass --admin-url https://<store>.myshopify.com.`,sleep:options.sleep},seen=new Set;let emitted=0,url=`${endpoint}?limit=${pageSize}&fields=${fields}${spec.firstQuery}`;for(let page=1;page<=MAX_PAGES;page++){const{data,headers}=await adminRequest(url,request),list=data?.[spec.resource];if(!Array.isArray(list))throw new CatalogFetchError(url,`${url} did not return a "${spec.resource}" list. Is this the Shopify Admin API?`);const raw=list,ids=raw.map((entry)=>String(entry?.id));if(page>1&&ids.length>0&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${url} repeated ${spec.resource} from an earlier page; stopping rather than looping.`);ids.forEach((id)=>seen.add(id));const items=[],warnings=[];for(const entry of raw){if(limit&&emitted>=limit)break;const mapped=spec.map(entry);warnings.push(...mapped.warnings);if(mapped.record){items.push(mapped.record);emitted++}}yield{url,items,warnings};const cursor=nextPageInfo(headers.get("link"));if(!cursor||raw.length===0||limit&&emitted>=limit)return;url=`${endpoint}?limit=${pageSize}&fields=${fields}&page_info=${encodeURIComponent(cursor)}`}throw new CatalogFetchError(endpoint,`Stopped after ${MAX_PAGES} pages of ${endpoint} without reaching the end.`)}export const shopifyAccountAdapter={name:"shopify",customers:(storeUrl,options)=>shopifyResource(storeUrl,options,{resource:"customers",fields:CUSTOMER_FIELDS,firstQuery:"",map:mapShopifyCustomer}),orders:(storeUrl,options)=>shopifyResource(storeUrl,options,{resource:"orders",fields:ORDER_FIELDS,firstQuery:"&status=any",map:(raw)=>mapShopifyOrder(raw,{currency:options.currency})})};
@@ -0,0 +1 @@
1
+ import{slug}from"@stacksjs/strings";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,decimalToMinor,PriceFormatError}from"./money";import{optionalString}from"./text";const PAGE_SIZE=250,MAX_PAGES=400;function isDefaultOptionSet(options){if(!options||options.length===0)return!0;if(options.length!==1)return!1;const[only]=options;return only?.name==="Title"&&(only.values?.length??0)<=1&&(only.values?.[0]??"Default Title")==="Default Title"}function tagsOf(tags){return(Array.isArray(tags)?tags:typeof tags==="string"?tags.split(","):[]).map((tag)=>String(tag).trim()).filter(Boolean)}function price(value,exponent,label,externalId,warnings){try{return decimalToMinor(value,exponent)}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; left unpriced`});return null}}export function mapShopifyProduct(raw,context){if(raw?.id===void 0||raw.id===null||raw.id==="")throw TypeError("Shopify product has no id");const externalId=String(raw.id),warnings=[],name=(optionalString(raw.title)??"").replace(/\s+/g," ");if(!name)throw TypeError(`Shopify product ${externalId} has no title`);const handle=optionalString(raw.handle)??slug(name),exponent=currencyExponent(context.currency),hasOptions=!isDefaultOptionSet(raw.options),optionNames=hasOptions?(raw.options??[]).map((option)=>optionalString(option.name)??"").filter(Boolean):[],variants=(raw.variants??[]).map((variant)=>{const variantId=String(variant.id),compareAt=price(variant.compare_at_price,exponent,`variant ${variantId} compare-at price`,externalId,warnings);return{externalId:variantId,title:optionalString(variant.title)??name,sku:optionalString(variant.sku),priceMinor:price(variant.price,exponent,`variant ${variantId} price`,externalId,warnings),compareAtMinor:compareAt?compareAt:null,available:variant.available!==!1,inventory:typeof variant.inventory_quantity==="number"?Math.max(0,Math.trunc(variant.inventory_quantity)):null,optionValues:hasOptions?[variant.option1,variant.option2,variant.option3].slice(0,optionNames.length).map((value)=>optionalString(value)??""):[]}});if(variants.length===0)warnings.push({externalId,message:`"${name}" has no variants; imported without a price`});const images=[...raw.images??[]].filter((image)=>optionalString(image?.src)).sort((a,b)=>(a.position??0)-(b.position??0)).map((image)=>({src:optionalString(image.src),alt:optionalString(image.alt)})),productType=optionalString(raw.product_type);return{product:{source:"shopify",externalId,name,handle,descriptionHtml:optionalString(raw.body_html),vendor:optionalString(raw.vendor),categories:productType?[{name:productType,slug:slug(productType)}]:[],tags:tagsOf(raw.tags),currency:context.currency,images,optionNames,variants,hasOptions,url:`${context.storeUrl}/products/${handle}`},warnings}}export async function detectShopifyCurrency(storeUrl,fetcher=fetch){try{const{data}=await fetchJson(`${storeUrl}/meta.json`,fetcher),code=typeof data?.currency==="string"?data.currency.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}catch{return null}}async function*shopifyPages(storeUrl,options={}){const base=normalizeStoreUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,setupWarnings=[];let currency=options.currency?.toUpperCase()??null;if(!currency){currency=await detectShopifyCurrency(base,fetcher);if(!currency)setupWarnings.push({message:`${base}/meta.json did not report a currency; prices read as 2-decimal amounts. Pass --currency to be sure.`})}const seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const url=`${base}/products.json?limit=${pageSize}&page=${page}`,{data}=await fetchJson(url,fetcher,"Is this a Shopify storefront, and is it public (no storefront password)?");if(!Array.isArray(data?.products))throw new CatalogFetchError(url,`${url} did not return a "products" list. Is this a Shopify storefront?`);const raw=data.products;if(raw.length===0)return;const ids=raw.map((product)=>String(product?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${url} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const products=[],warnings=page===1?setupWarnings:[];for(const entry of raw){if(limit&&emitted>=limit)break;try{const mapped=mapShopifyProduct(entry,{storeUrl:base,currency});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}}yield{url,products,warnings};if(limit&&emitted>=limit)return}throw new CatalogFetchError(base,`Stopped after ${MAX_PAGES} pages of ${base}/products.json without reaching the end.`)}export const shopifyAdapter={name:"shopify",pages:shopifyPages};
@@ -0,0 +1 @@
1
+ import{addressLine,amountOrZero,currencyCode,isoTimestamp,normalizeEmail,personName,phoneOf,quantityOf,signedMinor}from"./account-mapping";import{AdminCredentialError,adminRequest}from"./admin-http";import{ACCOUNT_CREDENTIAL_HELP,credentialSecrets}from"./credentials";import{CatalogFetchError,normalizeStoreUrl}from"./http";import{currencyExponent,PriceFormatError}from"./money";import{cleanName,optionalString}from"./text";const PAGE_SIZE=100,MAX_PAGES=1e4,CREDENTIAL_HELP=`Check SHOPWARE_CLIENT_ID and SHOPWARE_CLIENT_SECRET, and that the integration's role may read customers and orders. ${ACCOUNT_CREDENTIAL_HELP.shopware}`,HINT="Is this a Shopware 6 store with the Admin API reachable at /api?";export function mapShopwareCustomer(raw,context={}){const externalId=optionalString(raw?.id)??"",warnings=[],email=normalizeEmail(raw?.email);if(!externalId||!email){warnings.push({externalId:externalId||void 0,message:"skipped: the customer has no valid email address, which Stacks customers are keyed by"});return{record:null,warnings}}const currency=currencyCode(context.currency);let totalSpentMinor=null;try{totalSpentMinor=signedMinor(raw.orderTotalAmount??null,currencyExponent(currency))}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`orderTotalAmount: ${error.message}; not imported`})}return{record:{source:"shopware",externalId,email,name:personName(raw.firstName,raw.lastName)??optionalString(raw.company)??email,phone:phoneOf(raw.defaultBillingAddress?.phoneNumber),active:typeof raw.active==="boolean"?raw.active:null,totalSpentMinor:totalSpentMinor!==null&&totalSpentMinor>=0?totalSpentMinor:null,currency,lastOrderAt:isoTimestamp(raw.lastOrderDate),avatarUrl:null,createdAt:isoTimestamp(raw.createdAt)},warnings}}function stateOf(entity){return(entity?.stateMachineState?.technicalName??"").toLowerCase()}function paymentState(raw){const transactions=[...raw.transactions??[]].sort((a,b)=>String(a?.createdAt??"").localeCompare(String(b?.createdAt??"")));return stateOf(transactions.at(-1))}export function shopwareOrderStatus(raw){const order=stateOf(raw),delivery=stateOf(raw.deliveries?.[0]),payment=paymentState(raw),sourceStatus=`order ${order||"unknown"}, delivery ${delivery||"unknown"}, payment ${payment||"unknown"}`,result=(status,mapped=!0)=>({status,mapped,sourceStatus});if(payment==="refunded")return result("REFUNDED");if(order==="cancelled")return result("CANCELLED");if(order==="completed")return result("DELIVERED");if(order==="open"||order==="in_progress"){if(delivery==="shipped")return result("SHIPPED");if(order==="in_progress"||delivery==="shipped_partially"||["paid","paid_partially","authorized"].includes(payment))return result("PROCESSING");return result("PENDING")}return result("PENDING",!1)}function addressOf(address){if(!address||!optionalString(address.street))return null;return addressLine(personName(address.firstName,address.lastName),address.company,address.street,address.additionalAddressLine1,[address.zipcode,address.city].map((part)=>cleanName(part)).filter(Boolean).join(" "),address.country?.iso??address.country?.name)}function payloadOf(item){return item.payload&&typeof item.payload==="object"&&!Array.isArray(item.payload)?item.payload:{}}export function shopwareLineLabel(label,options){const values=Array.isArray(options)?options.map((option)=>cleanName(option?.option)).filter(Boolean):[];return values.length>0&&!values.every((value)=>label.includes(value))?`${label} - ${values.join(", ")}`:label}export function mapShopwareOrder(raw,context={}){const externalId=optionalString(raw?.id)??"",warnings=[];if(!externalId){warnings.push({message:"skipped: a Shopware order has no id"});return{record:null,warnings}}const currency=currencyCode(raw.currency?.isoCode)??currencyCode(context.currency);if(!currency){warnings.push({externalId,message:"skipped: the order names no currency; pass --currency"});return{record:null,warnings}}const exponent=currencyExponent(currency),amount=(value,label)=>amountOrZero(value,exponent,label,externalId,warnings),lines=[];let discountMinor=0;for(const item of raw.lineItems??[]){if(optionalString(item?.parentId))continue;const lineId=optionalString(item?.id)??"",name=cleanName(item?.label)||`Line ${lineId}`,type=(item?.type??"").toLowerCase();let total=null;try{total=signedMinor(item?.totalPrice??null,exponent)}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`line "${name}": ${error.message}; left out`});continue}if(type==="promotion"||type==="credit"||total!==null&&total<0){discountMinor+=Math.abs(total??0);continue}const quantity=quantityOf(item?.quantity);if(!lineId||quantity===null){warnings.push({externalId,message:`line "${name}" has no id or quantity; left out`});continue}const deleted=type==="product"&&item.productId===null,productId=type==="product"&&!deleted?optionalString(item.productId)??optionalString(item.referencedId):null,payload=payloadOf(item);lines.push({externalId:lineId,productExternalId:optionalString(payload.parentId)??productId,variantExternalId:productId,name:shopwareLineLabel(name,payload.options),sku:optionalString(payload.productNumber),quantity,unitPriceMinor:amount(item.unitPrice,`line "${name}" unit price`)})}const totalMinor=amount(raw.amountTotal,"total"),netMinor=raw.amountNet===null||raw.amountNet===void 0?totalMinor:amount(raw.amountNet,"net total"),{status,mapped,sourceStatus}=shopwareOrderStatus(raw);if(!mapped)warnings.push({externalId,message:`Shopware state "${sourceStatus}" has no Stacks equivalent; imported as ${status}`});const billing=(raw.addresses??[]).find((address)=>address?.id&&address.id===raw.billingAddressId),shipping=raw.deliveries?.[0]?.shippingOrderAddress??(raw.addresses??[]).find((address)=>address?.id&&address.id!==raw.billingAddressId)??null,email=normalizeEmail(raw.orderCustomer?.email),customerId=optionalString(raw.orderCustomer?.customerId);return{record:{source:"shopware",externalId,number:optionalString(raw.orderNumber),currency,status,sourceStatus,totalMinor,taxMinor:Math.max(0,totalMinor-netMinor),discountMinor,shippingMinor:amount(raw.shippingTotal,"shipping"),tipMinor:0,customer:email||customerId?{externalId:customerId,email,name:personName(raw.orderCustomer?.firstName,raw.orderCustomer?.lastName),phone:phoneOf(billing?.phoneNumber)}:null,shippingAddress:addressOf(shipping),note:optionalString(raw.customerComment),placedAt:isoTimestamp(raw.orderDateTime),lines},warnings}}export function shopwareAdminBase(storeUrl,adminUrl){const base=normalizeStoreUrl(adminUrl??storeUrl).replace(/\/(?:store-)?api$/i,""),{protocol,hostname}=new URL(base),loopback=hostname==="localhost"||hostname==="127.0.0.1"||hostname==="[::1]"||hostname.endsWith(".localhost");if(protocol!=="https:"&&!loopback)throw TypeError(`${base} is not HTTPS. The Shopware integration secret and bearer token must never travel in plain text.`);return base}export function shopwareCustomerCriteria(page,limit){return{page,limit,"total-count-mode":1,sort:[{field:"id",order:"ASC"}],associations:{defaultBillingAddress:{}},includes:{customer:["id","customerNumber","email","firstName","lastName","company","active","guest","orderTotalAmount","lastOrderDate","createdAt","defaultBillingAddress"],customer_address:["phoneNumber"]}}}const ADDRESS_FIELDS=["id","firstName","lastName","company","street","additionalAddressLine1","zipcode","city","phoneNumber","country"];export function shopwareOrderCriteria(page,limit){return{page,limit,"total-count-mode":1,sort:[{field:"id",order:"ASC"}],associations:{lineItems:{},addresses:{associations:{country:{}}},currency:{},stateMachineState:{},orderCustomer:{},deliveries:{associations:{stateMachineState:{},shippingOrderAddress:{associations:{country:{}}}}},transactions:{associations:{stateMachineState:{}}}},includes:{order:["id","orderNumber","orderDateTime","amountTotal","amountNet","shippingTotal","customerComment","billingAddressId","currency","stateMachineState","orderCustomer","lineItems","addresses","deliveries","transactions"],order_line_item:["id","parentId","productId","referencedId","label","quantity","unitPrice","totalPrice","type","payload"],order_customer:["customerId","email","firstName","lastName"],order_address:ADDRESS_FIELDS,order_delivery:["shippingOrderAddress","stateMachineState"],order_transaction:["stateMachineState","createdAt"],currency:["isoCode"],country:["iso","name"],state_machine_state:["technicalName"]}}}export async function shopwareAccessToken(base,clientId,clientSecret,request){const url=`${base}/api/oauth/token`,{data}=await adminRequest(url,{...request,json:{grant_type:"client_credentials",client_id:clientId,client_secret:clientSecret}}),token=typeof data?.access_token==="string"?data.access_token.trim():"";if(!token)throw new CatalogFetchError(url,`${url} returned no access_token. ${HINT}`);return token}async function*shopwareResource(storeUrl,options,entity,criteria,map){if(options.credentials.source!=="shopware")throw TypeError("The Shopware importer needs Shopware credentials (SHOPWARE_CLIENT_ID and SHOPWARE_CLIENT_SECRET).");const base=shopwareAdminBase(storeUrl,options.adminUrl),{clientId,clientSecret}=options.credentials,secrets=credentialSecrets(options.credentials),shared={fetch:options.fetch??fetch,secrets,credentialHelp:CREDENTIAL_HELP,hint:HINT,sleep:options.sleep};let token=await shopwareAccessToken(base,clientId,clientSecret,shared);const search=async(url,body)=>{const call=()=>adminRequest(url,{...shared,secrets:[...secrets,token],headers:{authorization:`Bearer ${token}`},json:body});try{return await call()}catch(error){if(!(error instanceof AdminCredentialError)||error.status!==401)throw error;token=await shopwareAccessToken(base,clientId,clientSecret,shared);return await call()}},url=`${base}/api/search/${entity}`,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const label=`${url} (page ${page})`,{data}=await search(url,criteria(page,pageSize));if(!Array.isArray(data?.data))throw new CatalogFetchError(url,`${label} did not return a "data" list. ${HINT}`);const raw=data.data;if(raw.length===0)return;const ids=raw.map((entry)=>String(entry?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${label} repeated ${entity}s from an earlier page; the store is not paginating.`);ids.forEach((id)=>seen.add(id));const items=[],warnings=[];for(const entry of raw){if(limit&&emitted>=limit)break;const mapped=map(entry);warnings.push(...mapped.warnings);if(mapped.record){items.push(mapped.record);emitted++}}yield{url:label,items,warnings};if(limit&&emitted>=limit)return;const total=typeof data.total==="number"?data.total:Number.NaN;if(raw.length<pageSize||Number.isInteger(total)&&page*pageSize>=total)return}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of ${url} without reaching the end.`)}export const shopwareAccountAdapter={name:"shopware",customers:(storeUrl,options)=>shopwareResource(storeUrl,options,"customer",shopwareCustomerCriteria,(raw)=>mapShopwareCustomer(raw,{currency:options.currency})),orders:(storeUrl,options)=>shopwareResource(storeUrl,options,"order",shopwareOrderCriteria,(raw)=>mapShopwareOrder(raw,{currency:options.currency}))};
@@ -0,0 +1 @@
1
+ import{slug}from"@stacksjs/strings";import{CatalogFetchError,fetchJson,normalizeStoreUrl}from"./http";import{currencyExponent,numberToMinor,PriceFormatError}from"./money";import{optionalString}from"./text";const PAGE_SIZE=100,MAX_PAGES=1000,API_PREFIX="/store-api",HINT="Is this a Shopware 6 store with the Store API reachable at /store-api?";export const SHOPWARE_ACCESS_KEY_HELP='Find it in the Shopware Administration under Sales Channels: open the storefront or headless sales channel and copy the key from its "API access" card. A headless storefront also ships it in its page source.';function translated(entity,field){if(!entity)return null;const record=entity,bag=record.translated;return(bag&&typeof bag==="object"&&!Array.isArray(bag)?optionalString(bag[field]):null)??optionalString(record[field])}function nameOf(entity){return translated(entity,"name")?.replace(/\s+/g," ")??null}function stockOf(raw){const value=typeof raw.availableStock==="number"?raw.availableStock:raw.stock;return typeof value==="number"&&Number.isFinite(value)?Math.max(0,Math.trunc(value)):null}function availableOf(raw){return raw.available!==!1&&raw.active!==!1}function pricesOf(raw,label,externalId,context,warnings){const exponent=currencyExponent(context.currency),unit=raw.calculatedPrice?.unitPrice;if(unit===null||unit===void 0||unit===""){warnings.push({externalId,message:`${label} has no price in ${context.currency??"the sales channel currency"}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}try{const priceMinor=numberToMinor(unit,exponent),listMinor=numberToMinor(raw.calculatedPrice?.listPrice?.price??null,exponent);return{priceMinor,compareAtMinor:listMinor!==null&&priceMinor!==null&&listMinor>priceMinor?listMinor:null}}catch(error){if(!(error instanceof PriceFormatError))throw error;warnings.push({externalId,message:`${label}: ${error.message}; left unpriced`});return{priceMinor:null,compareAtMinor:null}}}function imagesOf(raw){const entries=[...raw.media??[]].sort((a,b)=>(a?.position??0)-(b?.position??0));if(raw.cover)entries.unshift(raw.cover);const seen=new Set,images=[];for(const entry of entries){const src=optionalString(entry?.media?.url);if(!src||seen.has(src))continue;seen.add(src);const media=entry.media;images.push({src,alt:translated(media,"alt")??translated(media,"title")})}return images}function categoriesOf(raw){const seen=new Set,categories=[];for(const entry of[raw.seoCategory,...raw.categories??[]]){const name=nameOf(entry),key=name?slug(name):"";if(!name||!key||seen.has(key))continue;seen.add(key);categories.push({name,slug:key})}return categories}function optionAxes(children){const axes=new Map;for(const child of children)for(const option of child.options??[]){const name=nameOf(option.group),key=optionalString(option.groupId)??optionalString(option.group?.id)??name;if(!key||!name||axes.has(key))continue;axes.set(key,{key,name,position:option.group?.position??0,order:axes.size})}return[...axes.values()].sort((a,b)=>a.position-b.position||a.order-b.order)}function optionFor(child,axis){return(child.options??[]).find((option)=>(optionalString(option.groupId)??optionalString(option.group?.id)??nameOf(option.group))===axis.key)}export function mapShopwareProduct(raw,context){if(!optionalString(raw?.id))throw TypeError("Shopware product has no id");const externalId=raw.id,warnings=[],name=nameOf(raw);if(!name)throw TypeError(`Shopware product ${externalId} has no name`);const children=context.children??[],childCount=typeof raw.childCount==="number"?raw.childCount:0,hasOptions=children.length>0;let variants,optionNames=[];if(hasOptions){const axes=optionAxes(children);optionNames=axes.map((axis)=>axis.name);const rows=children.map((child)=>{const picked=axes.map((axis)=>optionFor(child,axis)),optionValues=picked.map((option)=>nameOf(option)??""),productNumber=optionalString(child.productNumber),label=`variant ${productNumber??child.id}`,prices=pricesOf(child,label,externalId,context,warnings);return{variant:{externalId:String(child.id),title:optionValues.filter(Boolean).join(" / ")||nameOf(child)||productNumber||name,sku:productNumber,priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(child),inventory:stockOf(child),optionValues},sortKey:picked.map((option)=>option?.position??0),productNumber:productNumber??""}});rows.sort((a,b)=>{for(let i=0;i<a.sortKey.length;i++){const delta=(a.sortKey[i]??0)-(b.sortKey[i]??0);if(delta!==0)return delta}return a.productNumber.localeCompare(b.productNumber)});variants=rows.map((row)=>row.variant)}else if(childCount>0){warnings.push({externalId,message:`"${name}" has ${childCount} variant${childCount===1?"":"s"} in Shopware, none visible to this sales channel; imported without a price`});variants=[]}else{const prices=pricesOf(raw,`"${name}"`,externalId,context,warnings);variants=[{externalId,title:name,sku:optionalString(raw.productNumber),priceMinor:prices.priceMinor,compareAtMinor:prices.compareAtMinor,available:availableOf(raw),inventory:stockOf(raw),optionValues:[]}]}return{product:{source:"shopware",externalId,name,handle:slug(name),descriptionHtml:translated(raw,"description"),vendor:nameOf(raw.manufacturer),categories:categoriesOf(raw),tags:(raw.tags??[]).map((tag)=>optionalString(tag?.name)).filter((tag)=>tag!==null),currency:context.currency,images:imagesOf(raw),optionNames,variants,hasOptions,url:`${context.storeUrl}/detail/${externalId}`},warnings}}export function shopwareBaseUrl(storeUrl){return normalizeStoreUrl(storeUrl).replace(/\/store-api$/i,"")}export function missingAccessKeyMessage(){return`Shopware's Store API needs the sales channel access key: pass --access-key <key> or set SHOPWARE_ACCESS_KEY. It is public by design and not a secret. ${SHOPWARE_ACCESS_KEY_HELP}`}function shopwareErrorDetail(body){if(!body)return null;try{const first=JSON.parse(body)?.errors?.[0];return optionalString(first?.detail)??optionalString(first?.title)}catch{return null}}function explain(error){const detail=shopwareErrorDetail(error.body),said=detail?` Shopware said: "${detail}"`:"";if(error.status===401||error.status===403||error.status===412)return new CatalogFetchError(error.url,`${error.message}${said} The sales channel access key looks wrong: check --access-key or SHOPWARE_ACCESS_KEY. ${SHOPWARE_ACCESS_KEY_HELP}`,error.status,error.body);return new CatalogFetchError(error.url,`${error.message}${said} ${HINT}`,error.status,error.body)}async function storeApi(url,accessKey,fetcher,json){try{const{data}=await fetchJson(url,fetcher,"",{headers:{"sw-access-key":accessKey},json});return data}catch(error){if(!(error instanceof CatalogFetchError))throw error;throw explain(error)}}function isKeyError(error){return error instanceof CatalogFetchError&&(error.status===401||error.status===403||error.status===412)}export async function detectShopwareCurrency(base,accessKey,fetcher=fetch){try{const data=await storeApi(`${base}${API_PREFIX}/context`,accessKey,fetcher),code=typeof data?.currency?.isoCode==="string"?data.currency.isoCode.trim().toUpperCase():"";return/^[A-Z]{3}$/.test(code)?code:null}catch(error){if(isKeyError(error))throw error;return null}}const INCLUDES={product:["id","parentId","productNumber","name","description","translated","calculatedPrice","stock","availableStock","available","active","childCount","manufacturer","seoCategory","categories","tags","cover","media","options"],calculated_price:["unitPrice","quantity","listPrice"],cart_list_price:["price"],product_media:["position","media"],media:["url","alt","title","translated"],product_manufacturer:["name","translated"],category:["name","translated"],tag:["name"],property_group_option:["id","name","translated","position","groupId","group"],property_group:["id","name","translated","position"]};export function shopwareProductCriteria(page,limit){return{page,limit,"total-count-mode":"exact",filter:[{type:"equals",field:"parentId",value:null}],sort:[{field:"id",order:"ASC"}],associations:{cover:{},media:{associations:{media:{}}},manufacturer:{},categories:{},tags:{}},includes:INCLUDES}}export function shopwareVariantCriteria(parentIds,page){return{page,limit:PAGE_SIZE,"total-count-mode":"exact",filter:[{type:"equalsAny",field:"parentId",value:parentIds}],sort:[{field:"id",order:"ASC"}],associations:{options:{associations:{group:{}}}},includes:INCLUDES}}function isLastPage(data,page,pageSize,received){if(received<pageSize)return!0;const total=typeof data.total==="number"?data.total:Number.NaN;return Number.isInteger(total)&&page*pageSize>=total}async function fetchChildren(base,accessKey,parentIds,fetcher){const found=new Map;if(parentIds.length===0)return found;const url=`${base}${API_PREFIX}/product`;for(let page=1;page<=MAX_PAGES;page++){const data=await storeApi(url,accessKey,fetcher,shopwareVariantCriteria(parentIds,page));if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${url} did not return a product list for the variants. ${HINT}`);const elements=data.elements;for(const child of elements){const parent=optionalString(child?.parentId);if(!parent)continue;const list=found.get(parent)??[];list.push(child);found.set(parent,list)}if(elements.length===0||isLastPage(data,page,PAGE_SIZE,elements.length))return found}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of variants from ${url} without reaching the end.`)}async function*shopwarePages(storeUrl,options={}){const accessKey=options.accessKey?.trim();if(!accessKey)throw TypeError(missingAccessKeyMessage());const base=shopwareBaseUrl(storeUrl),fetcher=options.fetch??fetch,limit=options.limit,pageSize=limit&&limit>0?Math.min(PAGE_SIZE,limit):PAGE_SIZE,url=`${base}${API_PREFIX}/product`,setupWarnings=[],override=options.currency?.trim().toUpperCase()||null,detected=await detectShopwareCurrency(base,accessKey,fetcher),currency=detected??override;if(detected&&override&&detected!==override)setupWarnings.push({message:`--currency ${override} ignored: this sales channel prices in ${detected}, and the Store API does not convert.`});if(!currency)setupWarnings.push({message:`${base}${API_PREFIX}/context did not report a currency; prices read as 2-decimal amounts. Pass --currency to be sure.`});const seen=new Set;let emitted=0;for(let page=1;page<=MAX_PAGES;page++){const label=`${url} (page ${page})`;let data;try{data=await storeApi(url,accessKey,fetcher,shopwareProductCriteria(page,pageSize))}catch(error){if(error instanceof CatalogFetchError)throw new CatalogFetchError(error.url,error.message.replace(error.url,label),error.status,error.body);throw error}if(!Array.isArray(data?.elements))throw new CatalogFetchError(url,`${label} did not return a product list. ${HINT}`);const elements=data.elements;if(elements.length===0)return;const ids=elements.map((element)=>String(element?.id));if(page>1&&ids.every((id)=>seen.has(id)))throw new CatalogFetchError(url,`${label} repeated products from an earlier page; the store is not paginating. Use --limit to import the first page only.`);ids.forEach((id)=>seen.add(id));const take=limit?elements.slice(0,Math.max(0,limit-emitted)):elements,warnings=page===1?setupWarnings:[],parents=take.filter((item)=>typeof item?.childCount==="number"&&item.childCount>0).map((item)=>String(item.id)),children=await fetchChildren(base,accessKey,parents,fetcher),products=[];for(const entry of take)try{const mapped=mapShopwareProduct(entry,{storeUrl:base,currency,children:children.get(String(entry?.id))});products.push(mapped.product);warnings.push(...mapped.warnings);emitted++}catch(error){warnings.push({externalId:entry?.id===void 0?void 0:String(entry.id),message:`skipped: ${error.message}`})}yield{url:label,products,warnings};if(limit&&emitted>=limit)return;if(isLastPage(data,page,pageSize,elements.length))return}throw new CatalogFetchError(url,`Stopped after ${MAX_PAGES} pages of ${url} without reaching the end.`)}export const shopwareAdapter={name:"shopware",pages:shopwarePages};
@@ -0,0 +1 @@
1
+ const NAMED={amp:"&",lt:"<",gt:">",quot:'"',apos:"'",nbsp:"\xA0",ndash:"\u2013",mdash:"\u2014",hellip:"\u2026",lsquo:"\u2018",rsquo:"\u2019",ldquo:"\u201C",rdquo:"\u201D",laquo:"\xAB",raquo:"\xBB",trade:"\u2122",reg:"\xAE",copy:"\xA9",deg:"\xB0",times:"\xD7",frac12:"\xBD",euro:"\u20AC",pound:"\xA3",yen:"\xA5",cent:"\xA2"};export function decodeHtmlEntities(input){return input.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi,(whole,ref)=>{if(ref[0]==="#"){const code=ref[1]==="x"||ref[1]==="X"?Number.parseInt(ref.slice(2),16):Number.parseInt(ref.slice(1),10);return Number.isInteger(code)&&code>0&&code<=1114111?String.fromCodePoint(code):whole}return NAMED[ref.toLowerCase()]??whole})}export function cleanName(input){if(typeof input!=="string")return"";return decodeHtmlEntities(input).replace(/\s+/g," ").trim()}export function optionalString(input){if(typeof input!=="string")return null;const trimmed=input.trim();return trimmed===""?null:trimmed}
File without changes