@thorprovider/medusa-extended 1.11.8 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thorprovider/medusa-extended",
3
- "version": "1.11.8",
3
+ "version": "1.12.0",
4
4
  "description": "Thor Commerce multi-tenant admin extensions for Medusa v2",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@thorprovider/adapters": "^5.0.8",
28
- "@thorprovider/types": "^5.3.8"
28
+ "@thorprovider/types": "^5.4.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "msw": "^2.12.3",
@@ -23,6 +23,7 @@ import type {
23
23
  ExtendedCreateDropshipperOrderBody,
24
24
  GetDropshipperProductsResponse,
25
25
  GetDropshipperCustomersResponse,
26
+ GetDropshipperShippingOptionsBody,
26
27
  GetDropshipperShippingOptionsResponse,
27
28
  GetDropshipperAccountResponse,
28
29
  } from '@thorprovider/types'
@@ -76,6 +77,28 @@ if (!runLive) {
76
77
  }
77
78
  }
78
79
 
80
+ /** Build a shipping-options request body for a variant (contract v2 POST). */
81
+ function buildShippingOptionsBody(
82
+ customerId: string,
83
+ variantId: string,
84
+ currencyCode: string,
85
+ address?: Partial<GetDropshipperShippingOptionsBody['shipping_address']>,
86
+ ): GetDropshipperShippingOptionsBody {
87
+ return {
88
+ customer_id: customerId,
89
+ currency_code: currencyCode,
90
+ shipping_address: {
91
+ address_1: '123 Test Street',
92
+ city: 'Test City',
93
+ province: 'Nuevo Leon',
94
+ postal_code: '00000',
95
+ country_code: 'mx',
96
+ ...address,
97
+ },
98
+ items: [{ variant_id: variantId, quantity: 1 }],
99
+ }
100
+ }
101
+
79
102
  beforeAll(async () => {
80
103
  // Stop MSW so real HTTP requests reach the backend
81
104
  server.close()
@@ -95,9 +118,18 @@ if (!runLive) {
95
118
  describe('Test data discovery', () => {
96
119
  let products: GetDropshipperProductsResponse['products']
97
120
  let customers: GetDropshipperCustomersResponse['customers']
98
- let shippingOptions: GetDropshipperShippingOptionsResponse['shipping_options']
121
+ let shippingGroups: GetDropshipperShippingOptionsResponse['groups']
99
122
  let account: GetDropshipperAccountResponse['account']
100
123
 
124
+ function getFirstVariantId(): string {
125
+ for (const product of products) {
126
+ if (product.variants && product.variants.length > 0) {
127
+ return product.variants[0].id
128
+ }
129
+ }
130
+ throw new Error('No products with variants found')
131
+ }
132
+
101
133
  it('fetches products to find valid variant IDs', async () => {
102
134
  const res = await client.getProducts({ limit: 5 })
103
135
  expect(res.products.length).toBeGreaterThan(0)
@@ -116,15 +148,15 @@ if (!runLive) {
116
148
  account = res.account
117
149
  })
118
150
 
119
- it('fetches shipping options for the account currency', async () => {
120
- const res = await client.getOrderShippingOptions({
121
- currency_code: account.currency_code,
122
- })
123
- expect(res.shipping_options.length).toBeGreaterThan(0)
124
- shippingOptions = res.shipping_options
151
+ it('fetches shipping groups for the account currency', async () => {
152
+ const res = await client.getOrderShippingOptions(
153
+ buildShippingOptionsBody(customers[0].id, getFirstVariantId(), account.currency_code),
154
+ )
155
+ expect(res.groups.length).toBeGreaterThan(0)
156
+ shippingGroups = res.groups
125
157
  })
126
158
 
127
- it('fetches shipping options with country code (geozone filter)', async () => {
159
+ it('fetches shipping groups with country code (geozone filter)', async () => {
128
160
  // Use the customer's default shipping address country if available
129
161
  const customerDetail = await client.getCustomer(customers[0].id)
130
162
  const addr = customerDetail.customer?.addresses?.find((a) => a.is_default_shipping)
@@ -133,15 +165,16 @@ if (!runLive) {
133
165
  return
134
166
  }
135
167
 
136
- const res = await client.getOrderShippingOptions({
137
- currency_code: account.currency_code,
138
- country_code: addr.country_code,
139
- province: addr.province ?? undefined,
140
- city: addr.city ?? undefined,
141
- })
142
- expect(res.shipping_options).toBeInstanceOf(Array)
168
+ const res = await client.getOrderShippingOptions(
169
+ buildShippingOptionsBody(customers[0].id, getFirstVariantId(), account.currency_code, {
170
+ country_code: addr.country_code,
171
+ province: addr.province ?? undefined,
172
+ city: addr.city ?? undefined,
173
+ }),
174
+ )
175
+ expect(res.groups).toBeInstanceOf(Array)
143
176
  console.log(
144
- `Shipping options for ${addr.country_code}/${addr.province ?? 'any'}/${addr.city ?? 'any'}: ${res.shipping_options.length} found`,
177
+ `Shipping groups for ${addr.country_code}/${addr.province ?? 'any'}/${addr.city ?? 'any'}: ${res.groups.length} found`,
145
178
  )
146
179
  })
147
180
  })
@@ -154,7 +187,7 @@ if (!runLive) {
154
187
  let products: GetDropshipperProductsResponse['products']
155
188
  let customers: GetDropshipperCustomersResponse['customers']
156
189
  let account: GetDropshipperAccountResponse['account']
157
- let shippingOptions: GetDropshipperShippingOptionsResponse['shipping_options']
190
+ let shippingGroups: GetDropshipperShippingOptionsResponse['groups']
158
191
  let createdOrderIds: string[] = []
159
192
 
160
193
  beforeAll(async () => {
@@ -167,10 +200,11 @@ if (!runLive) {
167
200
  customers = customersRes.customers
168
201
  account = accountRes.account
169
202
 
170
- const shippingRes = await client.getOrderShippingOptions({
171
- currency_code: account.currency_code,
172
- })
173
- shippingOptions = shippingRes.shipping_options
203
+ const variantId = getFirstVariantId()
204
+ const shippingRes = await client.getOrderShippingOptions(
205
+ buildShippingOptionsBody(customers[0].id, variantId, account.currency_code),
206
+ )
207
+ shippingGroups = shippingRes.groups
174
208
  })
175
209
 
176
210
  afterAll(async () => {
@@ -226,19 +260,29 @@ if (!runLive) {
226
260
  console.log(`Created order ${res.order.display_id} (${res.order.id}) — total: ${res.order.total}`)
227
261
  })
228
262
 
229
- it('creates an order with shipping_method_id', async () => {
230
- if (shippingOptions.length === 0) {
231
- console.log('Skipping: no shipping options available')
263
+ it('creates an order with shipping_groups', async () => {
264
+ if (shippingGroups.length === 0) {
265
+ console.log('Skipping: no shipping groups available')
232
266
  return
233
267
  }
234
268
 
235
- const body = buildOrderBody({ shipping_method_id: shippingOptions[0].id })
269
+ const shippingGroupsInput = shippingGroups
270
+ .map((g) => {
271
+ const option = g.options.find((o) => o.is_default) ?? g.options[0]
272
+ if (!option) return null
273
+ return { products: g.products, option_id: option.id }
274
+ })
275
+ .filter((g): g is { products: string[]; option_id: string } => g !== null)
276
+
277
+ const body = buildOrderBody({ shipping_groups: shippingGroupsInput })
236
278
  const res = await client.createOrder(body)
237
279
  expect(res.order.id).toBeTruthy()
238
280
  expect(res.order.status).toBe('pending')
281
+ expect(res.order.shipping_methods.length).toBe(shippingGroupsInput.length)
282
+ expect(res.order.shipping_total).toBeGreaterThan(0)
239
283
 
240
284
  createdOrderIds.push(res.order.id)
241
- console.log(`Created order with shipping method: ${shippingOptions[0].name}`)
285
+ console.log(`Created order with shipping groups: ${res.order.shipping_methods.map((m) => m.name).join(', ')}`)
242
286
  })
243
287
 
244
288
  it('creates an order with billing_address', async () => {
@@ -571,72 +615,82 @@ if (!runLive) {
571
615
 
572
616
  describe('Shipping options with geozone parameters', () => {
573
617
  let account: GetDropshipperAccountResponse['account']
618
+ let customerId: string
619
+ let variantId: string
574
620
 
575
621
  beforeAll(async () => {
576
- const res = await client.getAccount()
577
- account = res.account
622
+ const [accountRes, customersRes, productsRes] = await Promise.all([
623
+ client.getAccount(),
624
+ client.getCustomers({ limit: 1 }),
625
+ client.getProducts({ limit: 5 }),
626
+ ])
627
+ account = accountRes.account
628
+ customerId = customersRes.customers[0].id
629
+ const product = productsRes.products.find((p) => p.variants && p.variants.length > 0)
630
+ if (!product) throw new Error('No products with variants found')
631
+ variantId = product.variants[0].id
578
632
  })
579
633
 
580
- it('returns shipping options with currency_code only', async () => {
581
- const res = await client.getOrderShippingOptions({
582
- currency_code: account.currency_code,
583
- })
584
- expect(res.shipping_options).toBeInstanceOf(Array)
585
- console.log(`Shipping options (currency only): ${res.shipping_options.length} found`)
586
- if (res.shipping_options.length > 0) {
587
- expect(res.shipping_options[0]).toHaveProperty('id')
588
- expect(res.shipping_options[0]).toHaveProperty('name')
589
- expect(res.shipping_options[0]).toHaveProperty('amount')
634
+ it('returns shipping groups with currency only', async () => {
635
+ const res = await client.getOrderShippingOptions(
636
+ buildShippingOptionsBody(customerId, variantId, account.currency_code),
637
+ )
638
+ expect(res.groups).toBeInstanceOf(Array)
639
+ console.log(`Shipping groups (currency only): ${res.groups.length} found`)
640
+ if (res.groups.length > 0) {
641
+ const option = res.groups[0].options[0]
642
+ expect(option).toHaveProperty('id')
643
+ expect(option).toHaveProperty('name')
644
+ expect(option).toHaveProperty('amount')
590
645
  }
591
646
  })
592
647
 
593
- it('returns shipping options filtered by country_code', async () => {
594
- const res = await client.getOrderShippingOptions({
595
- currency_code: account.currency_code,
596
- country_code: 'mx',
597
- })
598
- expect(res.shipping_options).toBeInstanceOf(Array)
599
- console.log(`Shipping options (MX): ${res.shipping_options.length} found`)
648
+ it('returns shipping groups filtered by country_code', async () => {
649
+ const res = await client.getOrderShippingOptions(
650
+ buildShippingOptionsBody(customerId, variantId, account.currency_code, { country_code: 'mx' }),
651
+ )
652
+ expect(res.groups).toBeInstanceOf(Array)
653
+ console.log(`Shipping groups (MX): ${res.groups.length} found`)
600
654
  })
601
655
 
602
- it('returns shipping options filtered by country_code and province', async () => {
603
- const res = await client.getOrderShippingOptions({
604
- currency_code: account.currency_code,
605
- country_code: 'mx',
606
- province: 'Nuevo Leon',
607
- })
608
- expect(res.shipping_options).toBeInstanceOf(Array)
609
- console.log(`Shipping options (MX/Nuevo Leon): ${res.shipping_options.length} found`)
656
+ it('returns shipping groups filtered by country_code and province', async () => {
657
+ const res = await client.getOrderShippingOptions(
658
+ buildShippingOptionsBody(customerId, variantId, account.currency_code, {
659
+ country_code: 'mx',
660
+ province: 'Nuevo Leon',
661
+ }),
662
+ )
663
+ expect(res.groups).toBeInstanceOf(Array)
664
+ console.log(`Shipping groups (MX/Nuevo Leon): ${res.groups.length} found`)
610
665
  })
611
666
 
612
- it('returns shipping options filtered by country_code, province, and city', async () => {
613
- const res = await client.getOrderShippingOptions({
614
- currency_code: account.currency_code,
615
- country_code: 'mx',
616
- province: 'Nuevo Leon',
617
- city: 'Monterrey',
618
- })
619
- expect(res.shipping_options).toBeInstanceOf(Array)
620
- console.log(`Shipping options (MX/Nuevo Leon/Monterrey): ${res.shipping_options.length} found`)
667
+ it('returns shipping groups filtered by country_code, province, and city', async () => {
668
+ const res = await client.getOrderShippingOptions(
669
+ buildShippingOptionsBody(customerId, variantId, account.currency_code, {
670
+ country_code: 'mx',
671
+ province: 'Nuevo Leon',
672
+ city: 'Monterrey',
673
+ }),
674
+ )
675
+ expect(res.groups).toBeInstanceOf(Array)
676
+ console.log(`Shipping groups (MX/Nuevo Leon/Monterrey): ${res.groups.length} found`)
621
677
  })
622
678
 
623
679
  it('returns different results for different countries (geozone differentiation)', async () => {
624
680
  const [mxOptions, usOptions] = await Promise.all([
625
- client.getOrderShippingOptions({
626
- currency_code: account.currency_code,
627
- country_code: 'mx',
628
- }),
629
- client.getOrderShippingOptions({
630
- currency_code: account.currency_code,
631
- country_code: 'us',
632
- }),
681
+ client.getOrderShippingOptions(
682
+ buildShippingOptionsBody(customerId, variantId, account.currency_code, { country_code: 'mx' }),
683
+ ),
684
+ client.getOrderShippingOptions(
685
+ buildShippingOptionsBody(customerId, variantId, account.currency_code, { country_code: 'us' }),
686
+ ),
633
687
  ])
634
688
 
635
- console.log(`MX options: ${mxOptions.shipping_options.length}, US options: ${usOptions.shipping_options.length}`)
689
+ console.log(`MX groups: ${mxOptions.groups.length}, US groups: ${usOptions.groups.length}`)
636
690
 
637
691
  // The options MAY differ by country — log the difference
638
- const mxNames = mxOptions.shipping_options.map((o) => o.name).join(', ')
639
- const usNames = usOptions.shipping_options.map((o) => o.name).join(', ')
692
+ const mxNames = mxOptions.groups.flatMap((g) => g.options.map((o) => o.name)).join(', ')
693
+ const usNames = usOptions.groups.flatMap((g) => g.options.map((o) => o.name)).join(', ')
640
694
  console.log(`MX: [${mxNames}]`)
641
695
  console.log(`US: [${usNames}]`)
642
696
  })
@@ -590,23 +590,42 @@ describe('DropshipperClient.createOrder', () => {
590
590
  })
591
591
 
592
592
  describe('DropshipperClient.getOrderShippingOptions', () => {
593
- it('GET /admin/thor/dropshipper/orders/shipping-options', async () => {
593
+ it('POST /admin/thor/dropshipper/orders/shipping-options with body', async () => {
594
594
  server.use(
595
- http.get(`${BASE}/admin/thor/dropshipper/orders/shipping-options`, ({ request }) => {
595
+ http.post(`${BASE}/admin/thor/dropshipper/orders/shipping-options`, async ({ request }) => {
596
596
  requireBearerToken(request)
597
- const url = new URL(request.url)
598
- expect(url.searchParams.get('currency_code')).toBe('usd')
599
- expect(url.searchParams.get('country_code')).toBe('us')
597
+ const body = await request.json() as any
598
+ expect(body.currency_code).toBe('usd')
599
+ expect(body.customer_id).toBe('cus_1')
600
+ expect(body.shipping_address.country_code).toBe('us')
601
+ expect(body.items).toHaveLength(1)
600
602
  return HttpResponse.json({
601
- shipping_options: [
603
+ groups: [
602
604
  {
603
- id: 'ship_1',
604
- name: 'Express',
605
- amount: 1000,
606
- currency_code: 'usd',
607
- price_type: 'flat',
608
- is_default: true,
609
- type: null,
605
+ profile_id: 'sp_1',
606
+ profile_label: 'Estándar',
607
+ products: ['variant_1'],
608
+ items: [
609
+ {
610
+ variant_id: 'variant_1',
611
+ quantity: 1,
612
+ product_title: 'Product',
613
+ variant_title: 'Default',
614
+ },
615
+ ],
616
+ options: [
617
+ {
618
+ id: 'so_1',
619
+ name: 'Estándar',
620
+ amount: 500,
621
+ currency_code: 'usd',
622
+ price_type: 'flat',
623
+ is_default: true,
624
+ shipping_profile_id: 'sp_1',
625
+ stock_location_id: 'sloc_1',
626
+ type: null,
627
+ },
628
+ ],
610
629
  },
611
630
  ],
612
631
  count: 1,
@@ -614,30 +633,60 @@ describe('DropshipperClient.getOrderShippingOptions', () => {
614
633
  }),
615
634
  )
616
635
 
617
- const result = await client().getOrderShippingOptions({ currency_code: 'usd', country_code: 'us' })
618
- expect(result.shipping_options).toHaveLength(1)
619
- expect(result.shipping_options[0].id).toBe('ship_1')
636
+ const result = await client().getOrderShippingOptions({
637
+ customer_id: 'cus_1',
638
+ currency_code: 'usd',
639
+ shipping_address: {
640
+ address_1: '123 Main St',
641
+ city: 'Miami',
642
+ province: 'FL',
643
+ country_code: 'us',
644
+ },
645
+ items: [{ variant_id: 'variant_1', quantity: 1 }],
646
+ })
647
+ expect(result.groups).toHaveLength(1)
648
+ expect(result.groups[0].profile_label).toBe('Estándar')
649
+ expect(result.groups[0].options[0].id).toBe('so_1')
650
+ expect(result.count).toBe(1)
620
651
  })
621
652
 
622
- it('forwards province and city as query params', async () => {
653
+ it('forwards customer, address and items and returns groups', async () => {
623
654
  server.use(
624
- http.get(`${BASE}/admin/thor/dropshipper/orders/shipping-options`, ({ request }) => {
655
+ http.post(`${BASE}/admin/thor/dropshipper/orders/shipping-options`, async ({ request }) => {
625
656
  requireBearerToken(request)
626
- const url = new URL(request.url)
627
- expect(url.searchParams.get('currency_code')).toBe('usd')
628
- expect(url.searchParams.get('country_code')).toBe('cu')
629
- expect(url.searchParams.get('province')).toBe('La Habana')
630
- expect(url.searchParams.get('city')).toBe('Centro Habana')
657
+ const body = await request.json() as any
658
+ expect(body.currency_code).toBe('usd')
659
+ expect(body.customer_id).toBe('cus_1')
660
+ expect(body.shipping_address.province).toBe('La Habana')
661
+ expect(body.shipping_address.city).toBe('Centro Habana')
662
+ expect(body.items[0].variant_id).toBe('variant_1')
631
663
  return HttpResponse.json({
632
- shipping_options: [
664
+ groups: [
633
665
  {
634
- id: 'ship_1',
635
- name: 'La Habana Express',
636
- amount: 500,
637
- currency_code: 'usd',
638
- price_type: 'flat',
639
- is_default: true,
640
- type: null,
666
+ profile_id: 'sp_1',
667
+ profile_label: 'Estándar',
668
+ products: ['variant_1'],
669
+ items: [
670
+ {
671
+ variant_id: 'variant_1',
672
+ quantity: 1,
673
+ product_title: 'Product',
674
+ variant_title: 'Default',
675
+ },
676
+ ],
677
+ options: [
678
+ {
679
+ id: 'so_1',
680
+ name: 'La Habana Express',
681
+ amount: 500,
682
+ currency_code: 'usd',
683
+ price_type: 'flat',
684
+ is_default: true,
685
+ shipping_profile_id: 'sp_1',
686
+ stock_location_id: 'sloc_1',
687
+ type: null,
688
+ },
689
+ ],
641
690
  },
642
691
  ],
643
692
  count: 1,
@@ -646,13 +695,18 @@ describe('DropshipperClient.getOrderShippingOptions', () => {
646
695
  )
647
696
 
648
697
  const result = await client().getOrderShippingOptions({
698
+ customer_id: 'cus_1',
649
699
  currency_code: 'usd',
650
- country_code: 'cu',
651
- province: 'La Habana',
652
- city: 'Centro Habana',
700
+ shipping_address: {
701
+ address_1: 'Calle 1',
702
+ city: 'Centro Habana',
703
+ province: 'La Habana',
704
+ country_code: 'cu',
705
+ },
706
+ items: [{ variant_id: 'variant_1', quantity: 1 }],
653
707
  })
654
- expect(result.shipping_options).toHaveLength(1)
655
- expect(result.shipping_options[0].id).toBe('ship_1')
708
+ expect(result.groups).toHaveLength(1)
709
+ expect(result.groups[0].options[0].name).toBe('La Habana Express')
656
710
  })
657
711
  })
658
712
 
@@ -36,8 +36,8 @@ import type {
36
36
  // Orders
37
37
  GetDropshipperOrdersOptions,
38
38
  GetDropshipperOrdersResponse,
39
- GetDropshipperShippingOptionsParams,
40
- GetDropshipperShippingOptionsResponse,
39
+ GetDropshipperShippingOptionsBody,
40
+ GetDropshipperShippingOptionsResponse,
41
41
  GetDropshipperOrderDetailResponse,
42
42
  ExtendedCreateDropshipperOrderBody,
43
43
  ExtendedCreateDropshipperOrderResponse,
@@ -801,23 +801,20 @@ export class DropshipperClient {
801
801
  }
802
802
 
803
803
  /**
804
- * List shipping options for a manual dropshipper order.
804
+ * List shipping groups and their valid options for a manual dropshipper order.
805
805
  *
806
- * `GET /admin/thor/dropshipper/orders/shipping-options`
806
+ * `POST /admin/thor/dropshipper/orders/shipping-options`
807
807
  */
808
808
  async getOrderShippingOptions(
809
- params: GetDropshipperShippingOptionsParams,
809
+ body: GetDropshipperShippingOptionsBody,
810
810
  ): Promise<GetDropshipperShippingOptionsResponse> {
811
- const qs = new URLSearchParams({
812
- currency_code: params.currency_code.toLowerCase(),
813
- ...(params.country_code ? { country_code: params.country_code.toLowerCase() } : {}),
814
- ...(params.province ? { province: params.province } : {}),
815
- ...(params.city ? { city: params.city } : {}),
816
- });
817
-
818
811
  return this.request<GetDropshipperShippingOptionsResponse>(
819
- 'GET',
820
- `/admin/thor/dropshipper/orders/shipping-options?${qs}`,
812
+ 'POST',
813
+ '/admin/thor/dropshipper/orders/shipping-options',
814
+ {
815
+ ...body,
816
+ currency_code: body.currency_code.toLowerCase(),
817
+ },
821
818
  );
822
819
  }
823
820
 
package/src/recovery.ts CHANGED
@@ -14,15 +14,28 @@
14
14
  * - `POST /auth/user/emailpass/update` (confirm reset, consumes token)
15
15
  * - `GET /store/dropshipper/applications/activate/verify`(verify activation token, no consume)
16
16
  * - `POST /store/dropshipper/applications/activate` (confirm activation, consumes token)
17
+ * - `POST /public/dropshipper/applications` (submit application, no auth)
17
18
  *
18
19
  * Confirm methods follow the two-phase contract: verify (no consume) first,
19
20
  * then confirm (atomic consume). A re-verify after a failed confirm maps the
20
21
  * TOCTOU state (used/expired/revoked) into `RecoveryConfirmResult.status`.
22
+ *
23
+ * `submitApplication` returns a discriminated `SubmitApplicationResult` instead
24
+ * of throwing on 400/409/429 so the caller can surface the backend's structured
25
+ * payload (existing application on 409, retry window on 429). Network/5xx
26
+ * errors are re-thrown as `ProviderAPIError`.
21
27
  */
22
28
 
23
- import type { TokenVerifyResult, RecoveryConfirmResult } from '@thorprovider/types';
29
+ import type {
30
+ SubmitApplicationBody,
31
+ SubmitApplicationConflictApplication,
32
+ SubmitApplicationResponse,
33
+ SubmitApplicationResult,
34
+ TokenVerifyResult,
35
+ RecoveryConfirmResult,
36
+ } from '@thorprovider/types';
24
37
  import { createLogger, ProviderAPIError } from '@thorprovider/adapters';
25
- import { fetchBackendWithResilience } from './request-resilience';
38
+ import { fetchBackendWithResilience, type RetryConfig } from './request-resilience';
26
39
 
27
40
  type FetchMethod = 'GET' | 'POST';
28
41
 
@@ -66,7 +79,12 @@ export class RecoveryClient {
66
79
  // Private — HTTP helper
67
80
  // -------------------------------------------------------------------------
68
81
 
69
- private async request<T>(method: FetchMethod, path: string, body?: unknown): Promise<T> {
82
+ private async request<T>(
83
+ method: FetchMethod,
84
+ path: string,
85
+ body?: unknown,
86
+ retryConfig?: RetryConfig,
87
+ ): Promise<T> {
70
88
  const url = `${this.baseUrl}${path}`;
71
89
  this.logger.debug(`[recovery] ${method} ${path}`);
72
90
 
@@ -82,7 +100,7 @@ export class RecoveryClient {
82
100
 
83
101
  let response: Response;
84
102
  try {
85
- response = await fetchBackendWithResilience(url, init);
103
+ response = await fetchBackendWithResilience(url, init, retryConfig);
86
104
  } catch (networkError: any) {
87
105
  throw new ProviderAPIError(
88
106
  `[recovery] Network error on ${method} ${path}: ${networkError.message}`,
@@ -93,9 +111,11 @@ export class RecoveryClient {
93
111
 
94
112
  if (!response.ok) {
95
113
  let message = `HTTP ${response.status}`;
114
+ let details: Record<string, unknown> | undefined;
96
115
  try {
97
116
  const json = await response.json();
98
117
  message = json?.message ?? message;
118
+ details = json && typeof json === 'object' ? (json as Record<string, unknown>) : undefined;
99
119
  } catch {
100
120
  // ignore parse errors
101
121
  }
@@ -103,6 +123,7 @@ export class RecoveryClient {
103
123
  `[recovery] ${method} ${path} failed: ${message}`,
104
124
  `RECOVERY_API_${response.status}`,
105
125
  response.status,
126
+ details,
106
127
  );
107
128
  }
108
129
 
@@ -244,4 +265,64 @@ export class RecoveryClient {
244
265
  }
245
266
  }
246
267
  }
268
+
269
+ // -------------------------------------------------------------------------
270
+ // Dropshipper application submission
271
+ // -------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Envía una solicitud de acceso dropshipper al endpoint público.
275
+ *
276
+ * `POST /public/dropshipper/applications`
277
+ *
278
+ * No requiere autenticación. Devuelve un resultado discriminado:
279
+ * - `{ ok: true }` → 201, solicitud creada (`application.number`).
280
+ * - `{ ok: false, reason: 'conflict' }` → 409, email ya registrado; la
281
+ * solicitud existente incluye `status_label`/`reason` para la UI.
282
+ * - `{ ok: false, reason: 'rate_limited' }` → 429, se respetó el límite del
283
+ * backend; `retryAfterSeconds` indica cuándo reintentar.
284
+ * - `{ ok: false, reason: 'invalid_data' }` → 400, validación del backend.
285
+ *
286
+ * Los errores de red/5xx se re-lanzan como `ProviderAPIError` para que el
287
+ * llamador los categorice (p. ej. con `parseError`). El 429 no se reintenta
288
+ * (`retryableStatusCodes` sin 429) para que el caller reciba de inmediato el
289
+ * `retry_after_seconds` del backend y pueda bloquear el envío.
290
+ */
291
+ async submitApplication(body: SubmitApplicationBody): Promise<SubmitApplicationResult> {
292
+ try {
293
+ const response = await this.request<SubmitApplicationResponse>(
294
+ 'POST',
295
+ '/public/dropshipper/applications',
296
+ body,
297
+ { retryableStatusCodes: [408, 500, 502, 503, 504] },
298
+ );
299
+ return { ok: true, application: response.application };
300
+ } catch (error) {
301
+ if (error instanceof ProviderAPIError) {
302
+ const details = error.details ?? {};
303
+ if (error.status === 409 && details.application) {
304
+ return {
305
+ ok: false,
306
+ reason: 'conflict',
307
+ application: details.application as SubmitApplicationConflictApplication,
308
+ };
309
+ }
310
+ if (error.status === 429 && typeof details.retry_after_seconds === 'number') {
311
+ return {
312
+ ok: false,
313
+ reason: 'rate_limited',
314
+ retryAfterSeconds: details.retry_after_seconds,
315
+ };
316
+ }
317
+ if (error.status === 400) {
318
+ return {
319
+ ok: false,
320
+ reason: 'invalid_data',
321
+ fields: details.fields as Record<string, string[]> | undefined,
322
+ };
323
+ }
324
+ }
325
+ throw error;
326
+ }
327
+ }
247
328
  }
@@ -1,6 +1,6 @@
1
1
  import { ProviderAPIError, backendCircuitBreaker } from '@thorprovider/adapters'
2
2
 
3
- type RetryConfig = {
3
+ export type RetryConfig = {
4
4
  maxRetries?: number
5
5
  initialDelayMs?: number
6
6
  backoffMultiplier?: number