@fayz-ai/shop 0.7.0 → 0.8.2

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.
@@ -1,6 +1,7 @@
1
1
  import { getSupabaseClientOptional } from '@fayz-ai/core'
2
2
  import type { ShopProvider } from './provider'
3
3
  import { getShopTenantId } from './tenant'
4
+ import { T } from './tables'
4
5
  import type {
5
6
  Product, ProductImage, Category,
6
7
  Order, ShopCustomer, Discount,
@@ -8,8 +9,8 @@ import type {
8
9
  CreateCategoryInput, UpdateCategoryInput,
9
10
  CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
10
11
  CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
11
- CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
12
- } from './types'
12
+ CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions, OrderItem,
13
+ ShippingZone, CreateShippingZoneInput, UpdateShippingZoneInput } from './types'
13
14
 
14
15
  function getDb(): any {
15
16
  const supabase = getSupabaseClientOptional()
@@ -30,6 +31,44 @@ function asUuid(value: string | null | undefined): string | null {
30
31
  : null
31
32
  }
32
33
 
34
+ /** 8 bare digits, zero-padded — the shape shipping_zones stores and compares. */
35
+ function toPostalDigits(value: string | undefined | null): string | undefined {
36
+ const digits = String(value ?? '').replace(/\D/g, '')
37
+ return digits ? digits.padStart(8, '0').slice(0, 8) : undefined
38
+ }
39
+
40
+ function rowToShippingZone(row: any): ShippingZone {
41
+ return {
42
+ id: row.id,
43
+ tenantId: row.tenant_id,
44
+ name: row.name,
45
+ carrier: row.carrier ?? null,
46
+ postalFrom: String(row.postal_from ?? '').trim(),
47
+ postalTo: String(row.postal_to ?? '').trim(),
48
+ rate: Number(row.rate ?? 0),
49
+ freeAbove: row.free_above == null ? null : Number(row.free_above),
50
+ etaMinDays: row.eta_min_days ?? null,
51
+ etaMaxDays: row.eta_max_days ?? null,
52
+ active: row.active ?? true,
53
+ sortOrder: row.sort_order ?? 0,
54
+ }
55
+ }
56
+
57
+ function shippingZonePayload(input: Partial<CreateShippingZoneInput>): Record<string, unknown> {
58
+ const payload: Record<string, unknown> = {}
59
+ if (input.name !== undefined) payload.name = input.name
60
+ if (input.carrier !== undefined) payload.carrier = input.carrier || null
61
+ if (input.postalFrom !== undefined) payload.postal_from = toPostalDigits(input.postalFrom)
62
+ if (input.postalTo !== undefined) payload.postal_to = toPostalDigits(input.postalTo)
63
+ if (input.rate !== undefined) payload.rate = input.rate
64
+ if (input.freeAbove !== undefined) payload.free_above = input.freeAbove ?? null
65
+ if (input.etaMinDays !== undefined) payload.eta_min_days = input.etaMinDays ?? null
66
+ if (input.etaMaxDays !== undefined) payload.eta_max_days = input.etaMaxDays ?? null
67
+ if (input.active !== undefined) payload.active = input.active
68
+ if (input.sortOrder !== undefined) payload.sort_order = input.sortOrder
69
+ return payload
70
+ }
71
+
33
72
  function rowToProduct(row: any): Product {
34
73
  return {
35
74
  id: row.id,
@@ -81,6 +120,26 @@ function rowToCategory(row: any): Category {
81
120
  }
82
121
  }
83
122
 
123
+ /**
124
+ * Order items were being passed through raw, so every snake_case column arrived
125
+ * undefined on the camelCase type: unit_price, image_url and product_id were all
126
+ * lost. `total` survived only because the name matches in both, which is exactly
127
+ * why the bug looked like "price is zero" instead of "nothing is mapped".
128
+ */
129
+ function rowToOrderItem(row: any): OrderItem {
130
+ return {
131
+ id: row.id,
132
+ orderId: row.order_id,
133
+ productId: row.product_id ?? null,
134
+ name: row.name,
135
+ sku: row.sku ?? null,
136
+ quantity: row.quantity ?? 0,
137
+ unitPrice: row.unit_price ?? 0,
138
+ total: row.total ?? 0,
139
+ imageUrl: row.image_url ?? null,
140
+ }
141
+ }
142
+
84
143
  function rowToOrder(row: any): Order {
85
144
  return {
86
145
  id: row.id,
@@ -100,7 +159,10 @@ function rowToOrder(row: any): Order {
100
159
  customerEmail: row.customer_email,
101
160
  discountCode: row.discount_code,
102
161
  notes: row.notes,
103
- items: Array.isArray(row.items) ? row.items : [],
162
+ shippingAddress: row.shipping_address ?? null,
163
+ paymentMethodKind: row.payment_method_kind ?? null,
164
+ paidAt: row.paid_at ?? null,
165
+ items: Array.isArray(row.items) ? row.items.map(rowToOrderItem) : [],
104
166
  createdAt: row.created_at,
105
167
  updatedAt: row.updated_at,
106
168
  }
@@ -150,7 +212,7 @@ export class SupabaseShopProvider implements ShopProvider {
150
212
  async listProducts(options?: ListProductsOptions): Promise<Product[]> {
151
213
  const db = getDb()
152
214
  const tenantId = getTenantId()
153
- let q = db.from('shop_products').select('*, images:shop_product_images(*)')
215
+ let q = db.from(T.products).select(`*, images:${T.productImages}(*)`)
154
216
  if (tenantId) q = q.eq('tenant_id', tenantId)
155
217
  if (options?.status) q = q.eq('status', options.status)
156
218
  if (options?.categoryId) q = q.eq('category_id', options.categoryId)
@@ -167,8 +229,8 @@ export class SupabaseShopProvider implements ShopProvider {
167
229
  async getProduct(id: string): Promise<Product | null> {
168
230
  const db = getDb()
169
231
  const { data, error } = await db
170
- .from('shop_products')
171
- .select('*, images:shop_product_images(*)')
232
+ .from(T.products)
233
+ .select(`*, images:${T.productImages}(*)`)
172
234
  .eq('id', id)
173
235
  .maybeSingle()
174
236
  if (error) throw error
@@ -179,7 +241,7 @@ export class SupabaseShopProvider implements ShopProvider {
179
241
  const db = getDb()
180
242
  const tenantId = getTenantId()
181
243
  const { data, error } = await db
182
- .from('shop_products')
244
+ .from(T.products)
183
245
  .insert({
184
246
  tenant_id: tenantId,
185
247
  name: input.name,
@@ -198,7 +260,7 @@ export class SupabaseShopProvider implements ShopProvider {
198
260
  weight: input.weight ?? null,
199
261
  weight_unit: input.weightUnit ?? 'kg',
200
262
  })
201
- .select('*, images:shop_product_images(*)')
263
+ .select(`*, images:${T.productImages}(*)`)
202
264
  .single()
203
265
  if (error) throw error
204
266
  return rowToProduct(data)
@@ -220,15 +282,15 @@ export class SupabaseShopProvider implements ShopProvider {
220
282
  if (input.isPhysical !== undefined) updates.is_physical = input.isPhysical
221
283
  if (input.weight !== undefined) updates.weight = input.weight
222
284
  const { data, error } = await db
223
- .from('shop_products').update(updates).eq('id', id)
224
- .select('*, images:shop_product_images(*)')
285
+ .from(T.products).update(updates).eq('id', id)
286
+ .select(`*, images:${T.productImages}(*)`)
225
287
  .single()
226
288
  if (error) throw error
227
289
  return rowToProduct(data)
228
290
  }
229
291
 
230
292
  async deleteProduct(id: string): Promise<void> {
231
- const { error } = await getDb().from('shop_products').delete().eq('id', id)
293
+ const { error } = await getDb().from(T.products).delete().eq('id', id)
232
294
  if (error) throw error
233
295
  }
234
296
 
@@ -239,16 +301,63 @@ export class SupabaseShopProvider implements ShopProvider {
239
301
  const { error: uploadErr } = await db.storage.from('shop-images').upload(path, file)
240
302
  if (uploadErr) throw uploadErr
241
303
  const { data: { publicUrl } } = db.storage.from('shop-images').getPublicUrl(path)
304
+
305
+ // Every upload used to land as sort_order 0 / is_primary false, so a product
306
+ // whose only photo was uploaded here had NO primary at all, and a second one
307
+ // tied with the first for position. New photos now go to the end, and the
308
+ // first photo a product ever gets becomes its primary.
309
+ const { data: existing } = await db
310
+ .from(T.productImages)
311
+ .select('id,sort_order')
312
+ .eq('product_id', productId)
313
+ .order('sort_order', { ascending: false })
314
+ .limit(1)
315
+ const first = !existing || existing.length === 0
316
+ const nextOrder = first ? 0 : (existing[0].sort_order ?? 0) + 1
317
+
242
318
  const { data, error } = await db
243
- .from('shop_product_images')
244
- .insert({ product_id: productId, tenant_id: tenantId, url: publicUrl, sort_order: 0, is_primary: false })
319
+ .from(T.productImages)
320
+ .insert({
321
+ product_id: productId, tenant_id: tenantId, url: publicUrl,
322
+ sort_order: nextOrder, is_primary: first,
323
+ })
245
324
  .select().single()
246
325
  if (error) throw error
247
326
  return rowToImage(data)
248
327
  }
249
328
 
329
+ async updateProductImage(
330
+ imageId: string,
331
+ input: { isPrimary?: boolean; sortOrder?: number; altText?: string | null },
332
+ ): Promise<ProductImage> {
333
+ const db = getDb()
334
+ const updates: Record<string, unknown> = {}
335
+ if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder
336
+ if (input.altText !== undefined) updates.alt_text = input.altText
337
+ if (input.isPrimary !== undefined) updates.is_primary = input.isPrimary
338
+
339
+ // Demote the incumbent first. Doing it after would leave a window with two
340
+ // primaries, and if the second statement failed the product would keep two
341
+ // for good.
342
+ if (input.isPrimary) {
343
+ const { data: target } = await db
344
+ .from(T.productImages).select('product_id').eq('id', imageId).single()
345
+ if (target?.product_id) {
346
+ await db.from(T.productImages)
347
+ .update({ is_primary: false })
348
+ .eq('product_id', target.product_id)
349
+ .neq('id', imageId)
350
+ }
351
+ }
352
+
353
+ const { data, error } = await db
354
+ .from(T.productImages).update(updates).eq('id', imageId).select().single()
355
+ if (error) throw error
356
+ return rowToImage(data)
357
+ }
358
+
250
359
  async deleteProductImage(imageId: string): Promise<void> {
251
- const { error } = await getDb().from('shop_product_images').delete().eq('id', imageId)
360
+ const { error } = await getDb().from(T.productImages).delete().eq('id', imageId)
252
361
  if (error) throw error
253
362
  }
254
363
 
@@ -259,7 +368,7 @@ export class SupabaseShopProvider implements ShopProvider {
259
368
  async listCategories(): Promise<Category[]> {
260
369
  const db = getDb()
261
370
  const tenantId = getTenantId()
262
- let q = db.from('shop_categories').select('*').order('sort_order', { ascending: true })
371
+ let q = db.from(T.categories).select('*').order('sort_order', { ascending: true })
263
372
  if (tenantId) q = q.eq('tenant_id', tenantId)
264
373
  const { data, error } = await q
265
374
  if (error) throw error
@@ -269,7 +378,7 @@ export class SupabaseShopProvider implements ShopProvider {
269
378
  async createCategory(input: CreateCategoryInput): Promise<Category> {
270
379
  const db = getDb()
271
380
  const { data, error } = await db
272
- .from('shop_categories')
381
+ .from(T.categories)
273
382
  .insert({ tenant_id: getTenantId(), name: input.name, slug: input.slug ?? slugify(input.name), description: input.description ?? null, parent_id: input.parentId ?? null, sort_order: input.sortOrder ?? 0 })
274
383
  .select().single()
275
384
  if (error) throw error
@@ -284,13 +393,13 @@ export class SupabaseShopProvider implements ShopProvider {
284
393
  if (input.description !== undefined) updates.description = input.description
285
394
  if (input.parentId !== undefined) updates.parent_id = input.parentId
286
395
  if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder
287
- const { data, error } = await db.from('shop_categories').update(updates).eq('id', id).select().single()
396
+ const { data, error } = await db.from(T.categories).update(updates).eq('id', id).select().single()
288
397
  if (error) throw error
289
398
  return rowToCategory(data)
290
399
  }
291
400
 
292
401
  async deleteCategory(id: string): Promise<void> {
293
- const { error } = await getDb().from('shop_categories').delete().eq('id', id)
402
+ const { error } = await getDb().from(T.categories).delete().eq('id', id)
294
403
  if (error) throw error
295
404
  }
296
405
 
@@ -301,7 +410,7 @@ export class SupabaseShopProvider implements ShopProvider {
301
410
  async listOrders(options?: ListOrdersOptions): Promise<Order[]> {
302
411
  const db = getDb()
303
412
  const tenantId = getTenantId()
304
- let q = db.from('shop_orders').select('*, items:shop_order_items(*)')
413
+ let q = db.from(T.orders).select(`*, items:${T.orderItems}(*)`)
305
414
  if (tenantId) q = q.eq('tenant_id', tenantId)
306
415
  if (options?.status) q = q.eq('status', options.status)
307
416
  if (options?.financialStatus) q = q.eq('financial_status', options.financialStatus)
@@ -318,7 +427,7 @@ export class SupabaseShopProvider implements ShopProvider {
318
427
 
319
428
  async getOrder(id: string): Promise<Order | null> {
320
429
  const { data, error } = await getDb()
321
- .from('shop_orders').select('*, items:shop_order_items(*)')
430
+ .from(T.orders).select(`*, items:${T.orderItems}(*)`)
322
431
  .eq('id', id).maybeSingle()
323
432
  if (error) throw error
324
433
  return data ? rowToOrder(data) : null
@@ -332,12 +441,12 @@ export class SupabaseShopProvider implements ShopProvider {
332
441
  const shippingTotal = input.shippingTotal ?? 0
333
442
  const total = Math.round((subtotal - discountTotal + shippingTotal) * 100) / 100
334
443
  const { data: order, error: orderErr } = await db
335
- .from('shop_orders')
444
+ .from(T.orders)
336
445
  .insert({ tenant_id: tenantId, customer_id: input.customerId ?? null, customer_name: input.customerName ?? null, customer_email: input.customerEmail ?? null, currency: input.currency ?? 'BRL', notes: input.notes ?? null, subtotal, discount_total: discountTotal, shipping_total: shippingTotal, discount_code: input.discountCode ?? null, total, status: 'open', financial_status: 'pending', fulfillment_status: 'unfulfilled' })
337
446
  .select().single()
338
447
  if (orderErr) throw orderErr
339
448
  const items = input.items.map(i => ({ order_id: order.id, product_id: asUuid(i.productId), name: i.name, sku: i.sku ?? null, quantity: i.quantity, unit_price: i.unitPrice, total: i.quantity * i.unitPrice, image_url: i.imageUrl ?? null }))
340
- await db.from('shop_order_items').insert(items)
449
+ await db.from(T.orderItems).insert(items)
341
450
  return this.getOrder(order.id) as Promise<Order>
342
451
  }
343
452
 
@@ -347,11 +456,76 @@ export class SupabaseShopProvider implements ShopProvider {
347
456
  if (input.financialStatus !== undefined) updates.financial_status = input.financialStatus
348
457
  if (input.fulfillmentStatus !== undefined) updates.fulfillment_status = input.fulfillmentStatus
349
458
  if (input.notes !== undefined) updates.notes = input.notes
350
- const { error } = await getDb().from('shop_orders').update(updates).eq('id', id)
459
+ const { error } = await getDb().from(T.orders).update(updates).eq('id', id)
351
460
  if (error) throw error
352
461
  return this.getOrder(id) as Promise<Order>
353
462
  }
354
463
 
464
+ // -------------------------------------------------------------------------
465
+ // Delivery zones
466
+ //
467
+ // Postal codes are stored as 8 bare digits so the range test in
468
+ // shop_quote_shipping is a plain BETWEEN. Accepting '22.041-001' from the
469
+ // form and normalising here means the merchant never has to think about it.
470
+ // -------------------------------------------------------------------------
471
+ async listShippingZones(): Promise<ShippingZone[]> {
472
+ const tenantId = getTenantId()
473
+ const { data, error } = await getDb()
474
+ .from(T.shippingZones)
475
+ .select('*')
476
+ .eq('tenant_id', tenantId)
477
+ .order('sort_order', { ascending: true })
478
+ .order('postal_from', { ascending: true })
479
+ if (error) throw error
480
+ return (data ?? []).map(rowToShippingZone)
481
+ }
482
+
483
+ async createShippingZone(input: CreateShippingZoneInput): Promise<ShippingZone> {
484
+ const tenantId = getTenantId()
485
+ if (!tenantId) throw new Error('@fayz-ai/shop: createShippingZone requires a tenant id.')
486
+ const { data, error } = await getDb()
487
+ .from(T.shippingZones)
488
+ .insert({ tenant_id: tenantId, ...shippingZonePayload(input) })
489
+ .select()
490
+ .single()
491
+ if (error) throw error
492
+ return rowToShippingZone(data)
493
+ }
494
+
495
+ async updateShippingZone(id: string, input: UpdateShippingZoneInput): Promise<ShippingZone> {
496
+ const { data, error } = await getDb()
497
+ .from(T.shippingZones)
498
+ .update(shippingZonePayload(input))
499
+ .eq('id', id)
500
+ .select()
501
+ .single()
502
+ if (error) throw error
503
+ return rowToShippingZone(data)
504
+ }
505
+
506
+ async deleteShippingZone(id: string): Promise<void> {
507
+ const { error } = await getDb().from(T.shippingZones).delete().eq('id', id)
508
+ if (error) throw error
509
+ }
510
+
511
+ /**
512
+ * Settle a pending order: the merchant confirming that the money arrived.
513
+ *
514
+ * Goes through the RPC rather than an UPDATE so the payment ledger row in
515
+ * public.transactions closes in the same transaction as the order — set
516
+ * financial_status by hand and the order says paid while the ledger still
517
+ * says pending. Since 0019 the RPC also enforces who may call it: a member of
518
+ * the owning tenant, or a PSP webhook with the service role.
519
+ */
520
+ async confirmPayment(id: string, reference?: string): Promise<Order | null> {
521
+ const { error } = await getDb().rpc('shop_confirm_payment', {
522
+ p_order_id: id,
523
+ p_reference: reference ?? null,
524
+ })
525
+ if (error) throw error
526
+ return this.getOrder(id)
527
+ }
528
+
355
529
  // Trusted placement — delegates all price/discount/inventory authority to the
356
530
  // shop_place_order RPC (SECURITY DEFINER). The browser never sets a price.
357
531
  async placeOrder(input: PlaceOrderInput): Promise<Order> {
@@ -366,7 +540,25 @@ export class SupabaseShopProvider implements ShopProvider {
366
540
  p_customer_email: input.customer?.email ?? null,
367
541
  p_currency: input.currency ?? 'BRL',
368
542
  p_discount_code: input.discountCode ?? null,
369
- p_shipping_total: input.shippingTotal ?? 0,
543
+ p_shipping_total: input.shippingTotal ?? 0,
544
+ // snake_case keys on purpose: the RPC freezes this jsonb onto the
545
+ // order and public.addresses uses these exact column names.
546
+ p_shipping_address: input.shippingAddress
547
+ ? {
548
+ postal_code: input.shippingAddress.postalCode,
549
+ street: input.shippingAddress.street,
550
+ number: input.shippingAddress.number ?? null,
551
+ complement: input.shippingAddress.complement ?? null,
552
+ district: input.shippingAddress.district ?? null,
553
+ city: input.shippingAddress.city,
554
+ state: input.shippingAddress.state,
555
+ country: input.shippingAddress.country ?? 'BR',
556
+ recipient: input.shippingAddress.recipient ?? null,
557
+ phone: input.shippingAddress.phone ?? null,
558
+ label: input.shippingAddress.label ?? null,
559
+ }
560
+ : null,
561
+ p_payment_method: input.paymentMethod ?? null,
370
562
  p_notes: input.notes ?? null,
371
563
  })
372
564
  if (error) throw error
@@ -383,7 +575,7 @@ export class SupabaseShopProvider implements ShopProvider {
383
575
  async listCustomers(options?: ListCustomersOptions): Promise<ShopCustomer[]> {
384
576
  const db = getDb()
385
577
  const tenantId = getTenantId()
386
- let q = db.from('shop_customers').select('*').order('first_name', { ascending: true })
578
+ let q = db.from(T.customers).select('*').order('first_name', { ascending: true })
387
579
  if (tenantId) q = q.eq('tenant_id', tenantId)
388
580
  if (options?.search) q = q.or(`first_name.ilike.%${options.search}%,last_name.ilike.%${options.search}%,email.ilike.%${options.search}%`)
389
581
  if (options?.limit) q = q.limit(options.limit)
@@ -393,14 +585,14 @@ export class SupabaseShopProvider implements ShopProvider {
393
585
  }
394
586
 
395
587
  async getCustomer(id: string): Promise<ShopCustomer | null> {
396
- const { data, error } = await getDb().from('shop_customers').select('*').eq('id', id).maybeSingle()
588
+ const { data, error } = await getDb().from(T.customers).select('*').eq('id', id).maybeSingle()
397
589
  if (error) throw error
398
590
  return data ? rowToCustomer(data) : null
399
591
  }
400
592
 
401
593
  async createCustomer(input: CreateCustomerInput): Promise<ShopCustomer> {
402
594
  const { data, error } = await getDb()
403
- .from('shop_customers')
595
+ .from(T.customers)
404
596
  .insert({ tenant_id: getTenantId(), first_name: input.firstName, last_name: input.lastName ?? '', email: input.email ?? null, phone: input.phone ?? null, notes: input.notes ?? null })
405
597
  .select().single()
406
598
  if (error) throw error
@@ -414,13 +606,13 @@ export class SupabaseShopProvider implements ShopProvider {
414
606
  if (input.email !== undefined) updates.email = input.email
415
607
  if (input.phone !== undefined) updates.phone = input.phone
416
608
  if (input.notes !== undefined) updates.notes = input.notes
417
- const { data, error } = await getDb().from('shop_customers').update(updates).eq('id', id).select().single()
609
+ const { data, error } = await getDb().from(T.customers).update(updates).eq('id', id).select().single()
418
610
  if (error) throw error
419
611
  return rowToCustomer(data)
420
612
  }
421
613
 
422
614
  async deleteCustomer(id: string): Promise<void> {
423
- const { error } = await getDb().from('shop_customers').delete().eq('id', id)
615
+ const { error } = await getDb().from(T.customers).delete().eq('id', id)
424
616
  if (error) throw error
425
617
  }
426
618
 
@@ -448,7 +640,7 @@ export class SupabaseShopProvider implements ShopProvider {
448
640
  async listDiscounts(options?: ListDiscountsOptions): Promise<Discount[]> {
449
641
  const db = getDb()
450
642
  const tenantId = getTenantId()
451
- let q = db.from('shop_discounts').select('*').order('created_at', { ascending: false })
643
+ let q = db.from(T.discounts).select('*').order('created_at', { ascending: false })
452
644
  if (tenantId) q = q.eq('tenant_id', tenantId)
453
645
  if (options?.status) q = q.eq('status', options.status)
454
646
  if (options?.type) q = q.eq('type', options.type)
@@ -460,14 +652,14 @@ export class SupabaseShopProvider implements ShopProvider {
460
652
  }
461
653
 
462
654
  async getDiscount(id: string): Promise<Discount | null> {
463
- const { data, error } = await getDb().from('shop_discounts').select('*').eq('id', id).maybeSingle()
655
+ const { data, error } = await getDb().from(T.discounts).select('*').eq('id', id).maybeSingle()
464
656
  if (error) throw error
465
657
  return data ? rowToDiscount(data) : null
466
658
  }
467
659
 
468
660
  async createDiscount(input: CreateDiscountInput): Promise<Discount> {
469
661
  const { data, error } = await getDb()
470
- .from('shop_discounts')
662
+ .from(T.discounts)
471
663
  .insert({ tenant_id: getTenantId(), title: input.title, code: input.code ?? null, type: input.type, method: input.method ?? 'code', value: input.value, usage_limit: input.usageLimit ?? null, once_per_customer: input.oncePerCustomer ?? false, starts_at: input.startsAt ?? new Date().toISOString(), ends_at: input.endsAt ?? null, status: input.status ?? 'active', times_used: 0 })
472
664
  .select().single()
473
665
  if (error) throw error
@@ -483,13 +675,13 @@ export class SupabaseShopProvider implements ShopProvider {
483
675
  if (input.usageLimit !== undefined) updates.usage_limit = input.usageLimit
484
676
  if (input.status !== undefined) updates.status = input.status
485
677
  if (input.endsAt !== undefined) updates.ends_at = input.endsAt
486
- const { data, error } = await getDb().from('shop_discounts').update(updates).eq('id', id).select().single()
678
+ const { data, error } = await getDb().from(T.discounts).update(updates).eq('id', id).select().single()
487
679
  if (error) throw error
488
680
  return rowToDiscount(data)
489
681
  }
490
682
 
491
683
  async deleteDiscount(id: string): Promise<void> {
492
- const { error } = await getDb().from('shop_discounts').delete().eq('id', id)
684
+ const { error } = await getDb().from(T.discounts).delete().eq('id', id)
493
685
  if (error) throw error
494
686
  }
495
687
  }
package/src/tables.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Physical table names for @fayz-ai/shop. Renamed to the plg_shop_* industry-pool
2
+ // prefix (was shop_*) when the core DB moved into the public schema. RPC function
3
+ // names (shop_place_order, shop_get_order, shop_resolve_customer) are unchanged.
4
+ export const T = {
5
+ products: 'plg_shop_products',
6
+ productImages: 'plg_shop_product_images',
7
+ categories: 'plg_shop_categories',
8
+ orders: 'plg_shop_orders',
9
+ orderItems: 'plg_shop_order_items',
10
+ customers: 'plg_shop_customers',
11
+ discounts: 'plg_shop_discounts',
12
+ // Core, not plg_shop_*: a delivery zone is a fact about the business, and
13
+ // fulfilment and financial read it too. Same reasoning as addresses and
14
+ // orders — see migration 0021.
15
+ shippingZones: 'shipping_zones',
16
+ } as const
package/src/types.ts CHANGED
@@ -191,6 +191,11 @@ export interface Order {
191
191
  customerEmail: string | null
192
192
  discountCode: string | null
193
193
  notes: string | null
194
+ /** Frozen delivery address captured at purchase time (0010/0012). */
195
+ shippingAddress: ShippingAddressSnapshot | null
196
+ /** How the buyer paid — opens the ledger row in public.transactions. */
197
+ paymentMethodKind: PaymentMethodKind | null
198
+ paidAt: string | null
194
199
  items: OrderItem[]
195
200
  createdAt: string
196
201
  updatedAt: string
@@ -266,6 +271,106 @@ export interface PlaceOrderLine {
266
271
  * storefront order. Prices, discount math, and inventory are resolved
267
272
  * server-side; the client supplies product ids, quantities, and intent only.
268
273
  */
274
+ /** Structured delivery address. Mirrors public.addresses so the RPC can both
275
+ * freeze it on the order and save it to the customer's address book. */
276
+ export interface ShippingAddressInput {
277
+ postalCode: string
278
+ street: string
279
+ number?: string
280
+ complement?: string
281
+ district?: string
282
+ city: string
283
+ state: string
284
+ country?: string
285
+ recipient?: string
286
+ phone?: string
287
+ label?: string
288
+ }
289
+
290
+ export type PaymentMethodKind = 'pix' | 'credit_card' | 'debit_card' | 'boleto' | 'cash' | 'other'
291
+
292
+ /**
293
+ * A range of postal codes the store delivers to, with its own price and ETA.
294
+ *
295
+ * `postalFrom`/`postalTo` are 8 digits, no punctuation. Ranges may overlap: the
296
+ * cheapest match wins, which is how a merchant layers a promotional zone over a
297
+ * broader one without deleting anything.
298
+ */
299
+ export interface ShippingZone {
300
+ id: string
301
+ tenantId: string
302
+ name: string
303
+ carrier?: string | null
304
+ postalFrom: string
305
+ postalTo: string
306
+ rate: number
307
+ /** Overrides the store-wide free-delivery threshold inside this zone. */
308
+ freeAbove?: number | null
309
+ etaMinDays?: number | null
310
+ etaMaxDays?: number | null
311
+ active: boolean
312
+ sortOrder: number
313
+ }
314
+
315
+ export type CreateShippingZoneInput = Omit<ShippingZone, 'id' | 'tenantId'>
316
+ export type UpdateShippingZoneInput = Partial<CreateShippingZoneInput>
317
+
318
+ /**
319
+ * One delivery option for a postal code, as quoted by shop_quote_shipping.
320
+ *
321
+ * `rate` already has the zone's free-above threshold applied, so the caller
322
+ * never re-derives a price — the number shown is the number charged. An EMPTY
323
+ * list of options is the meaningful answer "we do not deliver there", not an
324
+ * error condition.
325
+ */
326
+ export interface ShippingQuoteOption {
327
+ zoneId: string
328
+ name: string
329
+ carrier?: string
330
+ /** Resolved against the subtotal the quote was requested for. */
331
+ rate: number
332
+ /** The zone's price before any free-above rule — lets a caller re-resolve
333
+ * `rate` for a changed cart without another round trip, using the same rule
334
+ * the server applies. Without it a stale quote had to fall back to the
335
+ * store-wide flat rate, which is a different zone's price. */
336
+ baseRate: number
337
+ freeAbove?: number | null
338
+ etaMinDays?: number
339
+ etaMaxDays?: number
340
+ /** True when the rate is 0 because the order cleared the zone's threshold. */
341
+ free: boolean
342
+ }
343
+
344
+ /**
345
+ * A row from the customer's address book (public.addresses).
346
+ *
347
+ * Only reachable with a signed-in shopper: the addresses_self_read policy joins
348
+ * plg_shop_customers.auth_user_id to auth.uid(), so an anonymous request sees
349
+ * nothing. Guests get an empty book and type their address, which is correct —
350
+ * the alternative is showing them somebody else's.
351
+ */
352
+ export interface CustomerAddress extends ShippingAddressInput {
353
+ id: string
354
+ isDefault?: boolean
355
+ }
356
+
357
+ /** Address as stored on the order: snake_case, mirroring public.addresses. */
358
+ export interface ShippingAddressSnapshot {
359
+ postal_code?: string
360
+ street?: string
361
+ number?: string
362
+ complement?: string
363
+ district?: string
364
+ city?: string
365
+ state?: string
366
+ country?: string
367
+ recipient?: string
368
+ phone?: string
369
+ label?: string
370
+ raw?: string
371
+ source?: string
372
+ }
373
+
269
374
  export interface PlaceOrderInput {
270
375
  /** Tenant/store id. The Supabase provider falls back to the resolved shop tenant. */
271
376
  tenantId?: string
@@ -279,6 +384,11 @@ export interface PlaceOrderInput {
279
384
  discountCode?: string
280
385
  /** Shipping cost from the store's shipping policy. Clamped to >= 0 server-side. */
281
386
  shippingTotal?: number
387
+ /** Structured delivery address. Without it the address survives only as free
388
+ * text in `notes`, which logistics cannot read. */
389
+ shippingAddress?: ShippingAddressInput
390
+ /** How the buyer is paying. Opens the ledger row in public.transactions. */
391
+ paymentMethod?: PaymentMethodKind
282
392
  items: PlaceOrderLine[]
283
393
  }
284
394