-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbilling.subscription.schema.js
More file actions
188 lines (170 loc) · 6.11 KB
/
billing.subscription.schema.js
File metadata and controls
188 lines (170 loc) · 6.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
* Module dependencies
*/
import { z } from 'zod';
import config from '../../../config/index.js';
/**
* Data Schema
*/
const objectIdRegex = /^[a-f\d]{24}$/i;
const optionalStripeId = z
.string()
.trim()
.optional()
.transform((val) => (val === '' ? undefined : val));
const baseShape = {
organization: z.string().trim().regex(objectIdRegex, 'organization must be a valid ObjectId'),
stripeCustomerId: optionalStripeId,
stripeSubscriptionId: optionalStripeId,
// ── Meter fields (optional — backward-compatible) ────────────────────────
planVersion: z.string().trim().optional(),
currentPeriodStart: z.coerce.date().nullable().optional(),
pastDueSince: z.coerce.date().nullable().optional(),
lastResetAt: z.coerce.date().nullable().optional(),
// Per-family event ordering guards
lastSubscriptionEventCreatedAt: z.number().int().nullable().optional(),
lastSubscriptionEventId: z.string().nullable().optional(),
lastInvoiceEventCreatedAt: z.number().int().nullable().optional(),
lastInvoiceEventId: z.string().nullable().optional(),
// Admin override audit trail
adminUpdatedAt: z.coerce.date().nullable().optional(),
adminUpdatedBy: z.string().regex(objectIdRegex).nullable().optional(),
// Pending cancellation — populated when customer schedules cancel-at-period-end
cancelAtPeriodEnd: z.boolean().optional(),
cancelAt: z.coerce.date().nullable().optional(),
};
const Subscription = z.object({
...baseShape,
plan: z.enum(config.billing.plans).default('free'),
status: z.enum(config.billing.statuses).default('active'),
});
/**
* Update schema without defaults to avoid populating unspecified fields during PATCH
*/
const SubscriptionCore = z.object({
...baseShape,
plan: z.enum(config.billing.plans),
status: z.enum(config.billing.statuses),
});
const SubscriptionUpdate = SubscriptionCore.partial();
/**
* Checkout request body schema
*/
const CheckoutRequest = z
.object({
priceId: z.string().trim().min(1, 'priceId is required'),
successUrl: z.string().url('successUrl must be a valid URL'),
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
})
.strict();
/**
* Portal request body schema
*/
const PortalRequest = z
.object({
returnUrl: z.string().url('returnUrl must be a valid URL').optional(),
})
.strict();
/**
* Extras checkout request body schema
*/
const ExtrasCheckoutRequest = z
.object({
packId: z.string().trim().min(1, 'packId is required'),
successUrl: z.string().url('successUrl must be a valid URL'),
cancelUrl: z.string().url('cancelUrl must be a valid URL'),
// Caller-provided stable intent ID for idempotency (prevents double-click double-charge).
// When absent, a soft-stable minute-bucketed key is used as fallback.
// Max 180 chars: Stripe idempotency key limit is 255; the prefix
// `extras_checkout_{orgId}_{packId}_` consumes ~64 chars, leaving ~10 chars margin.
intentId: z.string().min(1).max(180).optional(),
})
.strict();
const AdminRefundRequest = z
.object({
chargeId: z.string().trim().min(1, 'chargeId is required'),
amountCents: z.number().int().positive().optional(),
// Stripe refund reason: https://stripe.com/docs/api/refunds/create#create_refund-reason
reason: z.enum(['duplicate', 'fraudulent', 'requested_by_customer']).optional(),
// Caller-provided stable key for Stripe idempotency (prevents admin double-click double-refund).
// Required — frontend MUST generate a UUID per click and send it. Without this, two clicks
// separated by >1 minute could produce two separate Stripe refunds.
refundRequestId: z.string().min(8).max(128),
})
.strict();
const AdminBumpPlanRequest = z
.object({
orgId: z.string().regex(/^[a-f0-9]{24}$/i, 'orgId must be a valid ObjectId'),
planId: z.enum(config.billing.plans),
})
.strict();
/**
* Webhook replay request body schema.
* eventId: Stripe event ID (evt_xxx) to re-fetch and re-dispatch.
*/
const AdminWebhookReplayRequest = z
.object({
eventId: z.string().trim().min(1, 'eventId is required'),
})
.strict();
/**
* Dead-letters list query schema (GET parameters — all optional).
*/
const AdminDeadLettersQuery = z
.object({
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
})
.strict();
/**
* Dispute credit request body schema.
* Used by POST /api/admin/billing/dispute/credit to apply a manual ledger credit
* when Stripe rules in our favor on a dispute (funds_reinstated).
*
* chargeId — Stripe charge ID (ch_xxx) to correlate with the dispute.
* amountCents — Amount to credit in cents (positive integer).
* reason — Ops note for audit trail (3–200 chars).
* refundRequestId — UUID generated by the frontend per click for idempotency.
*/
const AdminDisputeCreditRequest = z
.object({
chargeId: z.string().regex(/^ch_/, 'chargeId must start with ch_'),
amountCents: z.number().int().positive('amountCents must be a positive integer'),
reason: z.string().min(3, 'reason must be at least 3 chars').max(200, 'reason must be at most 200 chars'),
refundRequestId: z.string().min(8, 'refundRequestId must be at least 8 characters'),
})
.strict();
/**
* Path parameter schemas for admin routes.
* orgId: MongoDB ObjectId (24 hex chars, case-insensitive).
* eventId: Stripe event ID (evt_ prefix).
*/
const AdminOrgIdParam = z.object({
orgId: z.string().regex(/^[a-f0-9]{24}$/i, 'orgId must be a valid ObjectId'),
});
const AdminEventIdParam = z.object({
eventId: z.string().regex(/^evt_/, 'eventId must be a Stripe event ID (evt_...)'),
});
export {
AdminRefundRequest,
AdminBumpPlanRequest,
AdminWebhookReplayRequest,
AdminDeadLettersQuery,
AdminDisputeCreditRequest,
AdminOrgIdParam,
AdminEventIdParam,
};
export default {
Subscription,
SubscriptionUpdate,
CheckoutRequest,
PortalRequest,
ExtrasCheckoutRequest,
AdminRefundRequest,
AdminBumpPlanRequest,
AdminWebhookReplayRequest,
AdminDeadLettersQuery,
AdminDisputeCreditRequest,
AdminOrgIdParam,
AdminEventIdParam,
};