Skip to content

Commit 9836a8e

Browse files
feat(billing): add Subscription model + repository (#3249)
* feat(billing): add Subscription model, schema, and repository Implement the Subscription Mongoose model with Stripe integration fields (stripeCustomerId, stripeSubscriptionId, plan, status, currentPeriodEnd, cancelAtPeriodEnd), Zod validation schema, and repository with CRUD + findByOrganization and findByStripeCustomerId queries. Closes #3241 * fix(billing): address review feedback on Subscription model - Validate organization as ObjectId format in Zod schema - Normalize empty Stripe IDs to undefined to prevent sparse index issues - Fix id/_id inconsistency in update/remove repository methods - Add defensive guard in findByStripeCustomerId for falsy input - Update JSDoc @return to @returns and add Promise return types - Add unit tests for ObjectId validation and Stripe ID normalization * refactor(billing): drive plan and status enums from config * fix(billing): correct update JSDoc return type to include null * fix(billing): address CodeRabbit review findings - Complete Stripe subscription status enum with all 8 official statuses - Fix resolveId to prevent invalid IDs from silently creating new records - Normalize stripeCustomerId by trimming before query * docs(skills): add multi-entity naming rule and config-driven enum guidance * refactor(organizations): use config.billing.plans for plan enum
1 parent b988c9a commit 9836a8e

11 files changed

Lines changed: 448 additions & 2 deletions

File tree

.claude/skills/create-module/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ Files to check in `modules/{new-module-name}/`:
5151
- Policy function names
5252
- Test descriptions and fixture data
5353

54+
### 4b. Config-driven enums
55+
56+
Business values (plans, roles, statuses) must be driven by the module config, never hardcoded in models or schemas.
57+
58+
Pattern:
59+
- Define values in `modules/{module}/config/{module}.development.config.js`
60+
- Reference in Mongoose model: `enum: config.{section}.{enumName}`
61+
- Reference in Zod schema: `z.enum(config.{section}.{enumName})`
62+
63+
Example: `config.billing.plans` used in both `billing.subscription.model.mongoose.js` and `billing.subscription.schema.js`.
64+
65+
> Existing reference: `users.schema.js` uses `z.enum(config.whitelists.users.roles)`.
66+
5467
### 5. Apply renames carefully
5568

5669
- Case-sensitive, whole-word matches where possible

.claude/skills/naming/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ Audit or apply the project's file and folder naming conventions.
3434

3535
> `[.{name}]` is optional — the reference `tasks` module uses the short form (e.g., `tasks.controller.js`, `tasks.service.js`).
3636
37+
### Multi-entity modules
38+
39+
When a module contains multiple entities (e.g., `billing` with `subscription`, `organizations` with `membership`), files are still prefixed by the **module name**, not the entity:
40+
41+
| Correct | Wrong |
42+
| --- | --- |
43+
| `billing.subscription.model.mongoose.js` | `subscription.model.mongoose.js` |
44+
| `billing.subscription.schema.js` | `subscription.schema.js` |
45+
| `organizations.membership.model.mongoose.js` | `membership.model.mongoose.js` |
46+
47+
Pattern: `{module}.{entity}[.{name}].{type}.js`
48+
3749
### Naming Tokens
3850

3951
From a module name (e.g., `my-feature`):

modules/billing/config/billing.development.config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
const config = {
2+
billing: {
3+
// Plans available for subscriptions — extend as needed
4+
plans: ['free', 'starter', 'pro', 'enterprise'],
5+
// Stripe subscription statuses — see https://docs.stripe.com/api/subscriptions/object#subscription_object-status
6+
statuses: [
7+
'incomplete',
8+
'incomplete_expired',
9+
'trialing',
10+
'active',
11+
'past_due',
12+
'canceled',
13+
'unpaid',
14+
'paused',
15+
],
16+
},
217
stripe: {
318
secretKey: process.env.DEVKIT_NODE_stripe_secretKey ?? '',
419
webhookSecret: process.env.DEVKIT_NODE_stripe_webhookSecret ?? '',

modules/billing/models/.gitkeep

Whitespace-only changes.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
import config from '../../../config/index.js';
7+
8+
const Schema = mongoose.Schema;
9+
10+
/**
11+
* Data Model Mongoose
12+
*/
13+
const SubscriptionMongoose = new Schema(
14+
{
15+
organization: {
16+
type: Schema.ObjectId,
17+
ref: 'Organization',
18+
unique: true,
19+
required: true,
20+
},
21+
stripeCustomerId: {
22+
type: String,
23+
unique: true,
24+
sparse: true,
25+
},
26+
stripeSubscriptionId: {
27+
type: String,
28+
unique: true,
29+
sparse: true,
30+
},
31+
plan: {
32+
type: String,
33+
enum: config.billing.plans,
34+
default: 'free',
35+
},
36+
status: {
37+
type: String,
38+
enum: config.billing.statuses,
39+
default: 'active',
40+
},
41+
currentPeriodEnd: {
42+
type: Date,
43+
},
44+
cancelAtPeriodEnd: {
45+
type: Boolean,
46+
default: false,
47+
},
48+
},
49+
{
50+
timestamps: true,
51+
},
52+
);
53+
54+
/**
55+
* Returns the hex string representation of the document ObjectId.
56+
* @returns {string} Hex string of the ObjectId.
57+
*/
58+
function addID() {
59+
return this._id.toHexString();
60+
}
61+
62+
/**
63+
* Model configuration
64+
*/
65+
SubscriptionMongoose.virtual('id').get(addID);
66+
// Ensure virtual fields are serialised.
67+
SubscriptionMongoose.set('toJSON', {
68+
virtuals: true,
69+
});
70+
71+
mongoose.model('Subscription', SubscriptionMongoose);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import { z } from 'zod';
5+
6+
import config from '../../../config/index.js';
7+
8+
/**
9+
* Data Schema
10+
*/
11+
const objectIdRegex = /^[a-f\d]{24}$/i;
12+
13+
const Subscription = z.object({
14+
organization: z
15+
.string()
16+
.trim()
17+
.regex(objectIdRegex, 'organization must be a valid ObjectId'),
18+
stripeCustomerId: z
19+
.string()
20+
.trim()
21+
.optional()
22+
.transform((val) => (val === '' ? undefined : val)),
23+
stripeSubscriptionId: z
24+
.string()
25+
.trim()
26+
.optional()
27+
.transform((val) => (val === '' ? undefined : val)),
28+
plan: z.enum(config.billing.plans).default('free'),
29+
status: z.enum(config.billing.statuses).default('active'),
30+
currentPeriodEnd: z.coerce.date().nullable().optional(),
31+
cancelAtPeriodEnd: z.boolean().default(false),
32+
});
33+
34+
const SubscriptionUpdate = Subscription.partial();
35+
36+
export default {
37+
Subscription,
38+
SubscriptionUpdate,
39+
};

modules/billing/repositories/.gitkeep

Whitespace-only changes.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import mongoose from 'mongoose';
5+
6+
const Subscription = mongoose.model('Subscription');
7+
8+
const defaultPopulate = [
9+
{
10+
path: 'organization',
11+
select: 'name slug plan',
12+
},
13+
];
14+
15+
/**
16+
* Resolves the identifier from a subscription object or raw id.
17+
* @param {Object|string} value - The subscription object or id string.
18+
* @returns {string|undefined} The resolved id.
19+
*/
20+
const resolveId = (value) => {
21+
if (typeof value === 'string' || value instanceof mongoose.Types.ObjectId) return value;
22+
return value?._id || value?.id;
23+
};
24+
25+
/**
26+
* @function list
27+
* @description Data access operation to fetch all subscriptions from the database with an optional filter.
28+
* @param {Object} [filter] - Optional filter to apply to the query.
29+
* @returns {Promise<Array>} A promise resolving to an array of subscriptions.
30+
*/
31+
const list = (filter) => Subscription.find(filter).populate(defaultPopulate).sort('-createdAt').exec();
32+
33+
/**
34+
* @function create
35+
* @description Data access operation to create a new subscription in the database.
36+
* @param {Object} subscription - The subscription object to create.
37+
* @returns {Promise<Object>} A promise resolving to the created subscription.
38+
*/
39+
const create = (subscription) => new Subscription(subscription).save().then((doc) => doc.populate(defaultPopulate));
40+
41+
/**
42+
* @function get
43+
* @description Data access operation to fetch a single subscription by its ID.
44+
* @param {String} id - The ID of the subscription to fetch.
45+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null if the ID is not valid.
46+
*/
47+
const get = (id) => {
48+
if (!mongoose.Types.ObjectId.isValid(id)) return null;
49+
return Subscription.findOne({ _id: id }).populate(defaultPopulate).exec();
50+
};
51+
52+
/**
53+
* @function update
54+
* @description Data access operation to update an existing subscription in the database.
55+
* @param {Object} subscription - The subscription object containing the updated details.
56+
* @returns {Promise<Object>|null} A promise resolving to the updated subscription, or null if the ID is invalid.
57+
*/
58+
const update = (subscription) => {
59+
const id = resolveId(subscription);
60+
if (id !== undefined) {
61+
if (!mongoose.Types.ObjectId.isValid(id)) return null;
62+
// eslint-disable-next-line no-unused-vars
63+
const { _id, id: _virtualId, ...payload } = subscription;
64+
return Subscription.findByIdAndUpdate(id, payload, { returnDocument: 'after', runValidators: true })
65+
.populate(defaultPopulate)
66+
.exec();
67+
}
68+
return new Subscription(subscription).save().then((doc) => doc.populate(defaultPopulate));
69+
};
70+
71+
/**
72+
* @function remove
73+
* @description Data access operation to delete a single subscription by its ID.
74+
* @param {Object} subscription - The subscription object to delete.
75+
* @returns {Promise<Object|null>} A promise resolving to a confirmation of the deletion or null if invalid.
76+
*/
77+
const remove = (subscription) => {
78+
const id = resolveId(subscription);
79+
if (!id || !mongoose.Types.ObjectId.isValid(id)) return null;
80+
return Subscription.deleteOne({ _id: id }).exec();
81+
};
82+
83+
/**
84+
* @function findByOrganization
85+
* @description Data access operation to fetch a subscription by organization ID.
86+
* @param {String} organizationId - The organization ID.
87+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
88+
*/
89+
const findByOrganization = (organizationId) => {
90+
if (!mongoose.Types.ObjectId.isValid(organizationId)) return null;
91+
return Subscription.findOne({ organization: organizationId }).populate(defaultPopulate).exec();
92+
};
93+
94+
/**
95+
* @function findByStripeCustomerId
96+
* @description Data access operation to fetch a subscription by Stripe customer ID.
97+
* @param {String} stripeCustomerId - The Stripe customer ID.
98+
* @returns {Promise<Object|null>} A promise resolving to the retrieved subscription or null.
99+
*/
100+
const findByStripeCustomerId = (stripeCustomerId) => {
101+
const normalizedStripeCustomerId = stripeCustomerId?.trim();
102+
if (!normalizedStripeCustomerId) return null;
103+
return Subscription.findOne({ stripeCustomerId: normalizedStripeCustomerId }).populate(defaultPopulate).exec();
104+
};
105+
106+
export default {
107+
list,
108+
create,
109+
get,
110+
update,
111+
remove,
112+
findByOrganization,
113+
findByStripeCustomerId,
114+
};

0 commit comments

Comments
 (0)