@startsimpli/billing 0.4.8 → 0.4.9

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": "@startsimpli/billing",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,2 +1,2 @@
1
- export { camelToSnake, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
1
+ export { camelToSnake, snakeToCamel, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
2
2
  export type { BillingProxyOptions, DjangoServiceTokenOptions } from './proxy';
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { snakeToCamel, camelToSnake, proxyBillingRequest } from './proxy';
3
+
4
+ describe('snakeToCamel', () => {
5
+ it('converts snake_case keys to camelCase recursively', () => {
6
+ const input = {
7
+ unit_price: '9.00',
8
+ is_free: false,
9
+ nested_obj: { interval_display: 'per month', is_recurring: true },
10
+ list_of_things: [{ cta_text: 'Get Started' }],
11
+ };
12
+ expect(snakeToCamel(input)).toEqual({
13
+ unitPrice: '9.00',
14
+ isFree: false,
15
+ nestedObj: { intervalDisplay: 'per month', isRecurring: true },
16
+ listOfThings: [{ ctaText: 'Get Started' }],
17
+ });
18
+ });
19
+
20
+ it('leaves string values untouched even when they contain underscores', () => {
21
+ expect(snakeToCamel({ feature_key: 'patterns_per_month' })).toEqual({
22
+ featureKey: 'patterns_per_month',
23
+ });
24
+ });
25
+
26
+ it('is idempotent for camelCase keys', () => {
27
+ const input = { alreadyCamel: true, nested: { stillCamel: 1 } };
28
+ expect(snakeToCamel(input)).toEqual(input);
29
+ });
30
+
31
+ it('is the inverse of camelToSnake', () => {
32
+ const camel = { unitPrice: '9.00', isFree: true, nested: { ctaText: 'Go' } };
33
+ expect(snakeToCamel(camelToSnake(camel))).toEqual(camel);
34
+ });
35
+ });
36
+
37
+ describe('proxyBillingRequest with fromSnake option', () => {
38
+ const originalFetch = globalThis.fetch;
39
+
40
+ beforeEach(() => {
41
+ globalThis.fetch = vi.fn();
42
+ });
43
+
44
+ afterEach(() => {
45
+ globalThis.fetch = originalFetch;
46
+ });
47
+
48
+ it('transforms snake_case response to camelCase when fromSnake is true', async () => {
49
+ (globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
50
+ new Response(
51
+ JSON.stringify({ unit_price: '9.00', is_free: false, offers: [{ cta_text: 'Go' }] }),
52
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
53
+ )
54
+ );
55
+
56
+ const response = await proxyBillingRequest(
57
+ 'http://django',
58
+ '/api/v1/billing/products/x/',
59
+ undefined,
60
+ { public: true, fromSnake: true }
61
+ );
62
+ const body = await response.json();
63
+ expect(body).toEqual({
64
+ unitPrice: '9.00',
65
+ isFree: false,
66
+ offers: [{ ctaText: 'Go' }],
67
+ });
68
+ });
69
+
70
+ it('leaves response untouched when no transform option is set', async () => {
71
+ (globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
72
+ new Response(JSON.stringify({ alreadyCamel: true }), { status: 200 })
73
+ );
74
+
75
+ const response = await proxyBillingRequest('http://django', '/path/', 'token');
76
+ const body = await response.json();
77
+ expect(body).toEqual({ alreadyCamel: true });
78
+ });
79
+
80
+ it('preserves non-OK status and error shape', async () => {
81
+ (globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
82
+ new Response('Not found', { status: 404 })
83
+ );
84
+
85
+ const response = await proxyBillingRequest(
86
+ 'http://django',
87
+ '/x/',
88
+ undefined,
89
+ { public: true, fromSnake: true }
90
+ );
91
+ expect(response.status).toBe(404);
92
+ });
93
+ });
@@ -87,11 +87,31 @@ export function camelToSnake(obj: unknown): unknown {
87
87
  return obj;
88
88
  }
89
89
 
90
+ /**
91
+ * Recursively converts snake_case object keys to camelCase.
92
+ * Used for Django endpoints that bypass the camel-case middleware
93
+ * (e.g. public product endpoints) so consumers always receive camelCase.
94
+ */
95
+ export function snakeToCamel(obj: unknown): unknown {
96
+ if (Array.isArray(obj)) return obj.map(snakeToCamel);
97
+ if (obj !== null && typeof obj === 'object') {
98
+ return Object.fromEntries(
99
+ Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
100
+ k.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()),
101
+ snakeToCamel(v),
102
+ ])
103
+ );
104
+ }
105
+ return obj;
106
+ }
107
+
90
108
  export interface BillingProxyOptions {
91
109
  method?: string;
92
110
  body?: unknown;
93
111
  /** If true, apply camelToSnake transform to the response body */
94
112
  normalize?: boolean;
113
+ /** If true, apply snakeToCamel transform to the response body */
114
+ fromSnake?: boolean;
95
115
  /** If true, skip Authorization header even when accessToken is provided */
96
116
  public?: boolean;
97
117
  }
@@ -110,7 +130,7 @@ export async function proxyBillingRequest(
110
130
  accessToken: string | undefined,
111
131
  options: BillingProxyOptions = {}
112
132
  ): Promise<NextResponse> {
113
- const { method = 'GET', body, normalize = false } = options;
133
+ const { method = 'GET', body, normalize = false, fromSnake = false } = options;
114
134
  const url = `${apiBaseUrl}${path}`;
115
135
 
116
136
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
@@ -135,7 +155,8 @@ export async function proxyBillingRequest(
135
155
  }
136
156
 
137
157
  const data = await response.json();
138
- return NextResponse.json(normalize ? camelToSnake(data) : data);
158
+ const transformed = normalize ? camelToSnake(data) : fromSnake ? snakeToCamel(data) : data;
159
+ return NextResponse.json(transformed);
139
160
  } catch (error) {
140
161
  return NextResponse.json(
141
162
  {