adonia 0.1.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/README.md +64 -0
- package/build/adonis_peers.d.ts +14 -0
- package/build/adonis_peers.js +56 -0
- package/build/cli.d.ts +2 -0
- package/build/cli.js +111 -0
- package/build/commands/add.d.ts +18 -0
- package/build/commands/add.js +246 -0
- package/build/commands/check.d.ts +4 -0
- package/build/commands/check.js +159 -0
- package/build/commands/diff.d.ts +2 -0
- package/build/commands/diff.js +74 -0
- package/build/commands/init.d.ts +6 -0
- package/build/commands/init.js +124 -0
- package/build/commands/list.d.ts +2 -0
- package/build/commands/list.js +34 -0
- package/build/config_merge.d.ts +10 -0
- package/build/config_merge.js +71 -0
- package/build/constants_merge.d.ts +4 -0
- package/build/constants_merge.js +85 -0
- package/build/copy.d.ts +8 -0
- package/build/copy.js +33 -0
- package/build/host.d.ts +15 -0
- package/build/host.js +85 -0
- package/build/registry/index.d.ts +26 -0
- package/build/registry/index.js +116 -0
- package/build/registry/plan.d.ts +53 -0
- package/build/registry/plan.js +159 -0
- package/build/schema.d.ts +136 -0
- package/build/schema.js +58 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/build/wire.d.ts +16 -0
- package/build/wire.js +162 -0
- package/package.json +51 -0
- package/registry/README.md +86 -0
- package/registry/account/contracts/avatar_storage.ts +7 -0
- package/registry/account/contracts/index.ts +7 -0
- package/registry/account/contracts/types.ts +23 -0
- package/registry/account/events.ts +14 -0
- package/registry/account/module.json +31 -0
- package/registry/account/options.ts +11 -0
- package/registry/account/service.ts +155 -0
- package/registry/account/stubs/adapters/account.ts +21 -0
- package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
- package/registry/account/stubs/controllers/account_controller.ts +190 -0
- package/registry/account/stubs/routes/account.ts +53 -0
- package/registry/account/stubs/validators/account.ts +37 -0
- package/registry/account/tests/account_service.spec.ts +132 -0
- package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
- package/registry/api/envelope.ts +66 -0
- package/registry/api/exception.ts +38 -0
- package/registry/api/exception_handler.ts +54 -0
- package/registry/api/module.json +27 -0
- package/registry/api/provider.ts +129 -0
- package/registry/api/service.ts +88 -0
- package/registry/api/tests/emit_safe.spec.ts +107 -0
- package/registry/api/tests/respond.spec.ts +40 -0
- package/registry/auth/contracts/index.ts +18 -0
- package/registry/auth/contracts/password_reset_store.ts +21 -0
- package/registry/auth/contracts/session_manager.ts +19 -0
- package/registry/auth/contracts/types.ts +32 -0
- package/registry/auth/events.ts +21 -0
- package/registry/auth/module.json +45 -0
- package/registry/auth/options.ts +23 -0
- package/registry/auth/service.ts +150 -0
- package/registry/auth/stubs/adapters/auth.ts +21 -0
- package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
- package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
- package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
- package/registry/auth/stubs/adapters/map_user.ts +19 -0
- package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
- package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
- package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
- package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
- package/registry/auth/stubs/models/password_reset.ts +29 -0
- package/registry/auth/stubs/models/user.ts +99 -0
- package/registry/auth/stubs/providers/vine_provider.ts +43 -0
- package/registry/auth/stubs/routes/auth.ts +36 -0
- package/registry/auth/stubs/start/limiter.ts +17 -0
- package/registry/auth/stubs/validators/auth.ts +48 -0
- package/registry/auth/stubs/validators/rules/exists.ts +21 -0
- package/registry/auth/stubs/validators/rules/unique.ts +21 -0
- package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
- package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
- package/registry/auth/stubs/validators/vine.d.ts +10 -0
- package/registry/auth/tests/auth_service.spec.ts +145 -0
- package/registry/auth/tests/auth_update_password.spec.ts +99 -0
- package/registry/auth/tests/fakes/index.ts +3 -0
- package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
- package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
- package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
- package/registry/constants/exceptions.ts +64 -0
- package/registry/constants/index.ts +12 -0
- package/registry/constants/responseCodes.ts +87 -0
- package/registry/contracts/index.ts +5 -0
- package/registry/contracts/user.ts +78 -0
- package/registry/creem/contracts/creem_client.ts +34 -0
- package/registry/creem/contracts/index.ts +8 -0
- package/registry/creem/contracts/types.ts +30 -0
- package/registry/creem/events.ts +9 -0
- package/registry/creem/module.json +28 -0
- package/registry/creem/service.ts +87 -0
- package/registry/creem/stubs/adapters/creem.ts +23 -0
- package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
- package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
- package/registry/notification/contracts/app_notification_store.ts +8 -0
- package/registry/notification/contracts/index.ts +9 -0
- package/registry/notification/contracts/mail_transport.ts +7 -0
- package/registry/notification/contracts/types.ts +32 -0
- package/registry/notification/module.json +23 -0
- package/registry/notification/options.ts +11 -0
- package/registry/notification/service.ts +103 -0
- package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
- package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
- package/registry/notification/stubs/adapters/notification.ts +36 -0
- package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
- package/registry/notification/stubs/models/app_notification.ts +45 -0
- package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
- package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
- package/registry/notification/tests/notification_service.spec.ts +63 -0
- package/registry/registry.json +4 -0
- package/registry/subscription/contracts/index.ts +9 -0
- package/registry/subscription/contracts/subscription_store.ts +15 -0
- package/registry/subscription/contracts/types.ts +39 -0
- package/registry/subscription/events.ts +13 -0
- package/registry/subscription/module.json +31 -0
- package/registry/subscription/options.ts +14 -0
- package/registry/subscription/service.ts +140 -0
- package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
- package/registry/subscription/stubs/adapters/subscription.ts +21 -0
- package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
- package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
- package/registry/subscription/stubs/models/subscription.ts +45 -0
- package/registry/subscription/tests/billing_service.spec.ts +189 -0
- package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
- package/registry/types/api.ts +41 -0
- package/registry/types/constants.ts +7 -0
- package/registry/types/index.ts +6 -0
- package/registry/types/models.ts +32 -0
- package/schema/adonia.schema.json +39 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionService
|
|
3
|
+
*
|
|
4
|
+
* Generic subscription state + Creem webhook sync.
|
|
5
|
+
* Every webhook handler upserts (idempotent, order-independent).
|
|
6
|
+
* Plan default comes from CreemService.defaultPlan.
|
|
7
|
+
*/
|
|
8
|
+
import ApiService from '#modules/api/service'
|
|
9
|
+
import type { CreemService } from '#modules/creem/service'
|
|
10
|
+
import type {
|
|
11
|
+
Subscription,
|
|
12
|
+
SubscriptionConfig,
|
|
13
|
+
SubscriptionSnapshot,
|
|
14
|
+
SubscriptionStore,
|
|
15
|
+
} from '#modules/subscription/contracts/index'
|
|
16
|
+
import { defaultSubscriptionOptions } from '#modules/subscription/options'
|
|
17
|
+
import '#modules/subscription/events'
|
|
18
|
+
|
|
19
|
+
export class SubscriptionService extends ApiService {
|
|
20
|
+
namespace = 'subscription'
|
|
21
|
+
|
|
22
|
+
private activeStatuses: string[]
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private subscriptions: SubscriptionStore,
|
|
26
|
+
private creem: CreemService,
|
|
27
|
+
config: SubscriptionConfig = {}
|
|
28
|
+
) {
|
|
29
|
+
super()
|
|
30
|
+
this.activeStatuses = config.activeStatuses ?? defaultSubscriptionOptions.activeStatuses
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns the user's current subscription record, or null if none exists.
|
|
35
|
+
*/
|
|
36
|
+
async getSubscription(userId: number): Promise<Subscription | null> {
|
|
37
|
+
return this.subscriptions.findCurrentByUserId(userId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the plan key via Creem product→plan map when the subscription
|
|
42
|
+
* is active; otherwise returns CreemService.defaultPlan.
|
|
43
|
+
*/
|
|
44
|
+
async getUserPlan(userId: number): Promise<string> {
|
|
45
|
+
const sub = await this.getSubscription(userId)
|
|
46
|
+
if (!sub || !this.activeStatuses.includes(sub.status)) {
|
|
47
|
+
return this.creem.defaultPlan
|
|
48
|
+
}
|
|
49
|
+
return this.creem.resolveProductPlan(sub.creemProductId)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Billing period start. Without a subscription, uses start of the current UTC month.
|
|
54
|
+
*/
|
|
55
|
+
async getCurrentPeriodStart(userId: number): Promise<Date> {
|
|
56
|
+
const sub = await this.getSubscription(userId)
|
|
57
|
+
if (sub?.currentPeriodStart) {
|
|
58
|
+
return sub.currentPeriodStart
|
|
59
|
+
}
|
|
60
|
+
const now = new Date()
|
|
61
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when the user has a subscription in an active status.
|
|
66
|
+
*/
|
|
67
|
+
async isActive(userId: number): Promise<boolean> {
|
|
68
|
+
const sub = await this.subscriptions.findActiveByUserId(userId, this.activeStatuses)
|
|
69
|
+
return sub !== null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Processes a Creem webhook. SDK verifies signature; every handler upserts.
|
|
74
|
+
*/
|
|
75
|
+
async syncFromWebhook(rawBody: string, signature: string): Promise<void> {
|
|
76
|
+
await this.creem.handleWebhookEvents(rawBody, signature, {
|
|
77
|
+
onGrantAccess: async (context) => {
|
|
78
|
+
const snapshot = this.toSnapshot(context)
|
|
79
|
+
if (!snapshot) return
|
|
80
|
+
await this.subscriptions.upsert(snapshot)
|
|
81
|
+
this.emitSafe('Subscription:Activated', { userId: snapshot.userId })
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
onRevokeAccess: async (context) => {
|
|
85
|
+
const snapshot = this.toSnapshot(context)
|
|
86
|
+
if (!snapshot) return
|
|
87
|
+
await this.subscriptions.upsert(snapshot)
|
|
88
|
+
this.emitSafe('Subscription:Revoked', { userId: snapshot.userId })
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
onSubscriptionCanceled: async (data) => {
|
|
92
|
+
const snapshot = this.toSnapshot(data)
|
|
93
|
+
if (!snapshot) return
|
|
94
|
+
await this.subscriptions.upsert(snapshot)
|
|
95
|
+
this.emitSafe('Subscription:Canceled', { userId: snapshot.userId })
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
onSubscriptionScheduledCancel: async (data) => {
|
|
99
|
+
const snapshot = this.toSnapshot(data)
|
|
100
|
+
if (!snapshot) return
|
|
101
|
+
await this.subscriptions.upsert(snapshot)
|
|
102
|
+
this.emitSafe('Subscription:ScheduledCancel', { userId: snapshot.userId })
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
onSubscriptionUpdate: async (data) => {
|
|
106
|
+
const snapshot = this.toSnapshot(data)
|
|
107
|
+
if (!snapshot) return
|
|
108
|
+
await this.subscriptions.upsert(snapshot)
|
|
109
|
+
this.emitSafe('Subscription:Updated', { userId: snapshot.userId })
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
onRefundCreated: async (data) => {
|
|
113
|
+
this.emitSafe('Subscription:RefundCreated', { refundId: String(data?.id ?? '') })
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Maps a Creem webhook payload into a store snapshot.
|
|
120
|
+
* Requires metadata.userId (set at checkout).
|
|
121
|
+
*/
|
|
122
|
+
private toSnapshot(payload: any): SubscriptionSnapshot | null {
|
|
123
|
+
const userId = Number(payload?.metadata?.userId)
|
|
124
|
+
if (!userId || Number.isNaN(userId)) {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
id: String(payload.id),
|
|
130
|
+
userId,
|
|
131
|
+
customerId: String(payload.customer.id),
|
|
132
|
+
productId: String(payload.product.id),
|
|
133
|
+
productName: String(payload.product.name),
|
|
134
|
+
status: String(payload.status),
|
|
135
|
+
currentPeriodStart: new Date(payload.currentPeriodStartDate),
|
|
136
|
+
currentPeriodEnd: new Date(payload.currentPeriodEndDate),
|
|
137
|
+
canceledAt: payload.canceledAt ? new Date(payload.canceledAt) : null,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lucid SubscriptionStore. Copied to app/adapters/lucid_subscription_store.ts.
|
|
3
|
+
* Expects a Subscription model at #models/subscription.
|
|
4
|
+
*/
|
|
5
|
+
import { DateTime } from 'luxon'
|
|
6
|
+
import SubscriptionModel from '#models/subscription'
|
|
7
|
+
import type {
|
|
8
|
+
Subscription,
|
|
9
|
+
SubscriptionSnapshot,
|
|
10
|
+
SubscriptionStore,
|
|
11
|
+
} from '#modules/subscription/contracts/index'
|
|
12
|
+
|
|
13
|
+
function toSubscription(row: InstanceType<typeof SubscriptionModel>): Subscription {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
userId: row.userId,
|
|
17
|
+
creemCustomerId: row.creemCustomerId,
|
|
18
|
+
creemSubscriptionId: row.creemSubscriptionId,
|
|
19
|
+
creemProductId: row.creemProductId,
|
|
20
|
+
plan: row.plan,
|
|
21
|
+
status: row.status,
|
|
22
|
+
currentPeriodStart: row.currentPeriodStart?.toJSDate() ?? null,
|
|
23
|
+
currentPeriodEnd: row.currentPeriodEnd?.toJSDate() ?? null,
|
|
24
|
+
canceledAt: row.canceledAt?.toJSDate() ?? null,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function period(date: Date) {
|
|
29
|
+
return DateTime.fromJSDate(date)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class LucidSubscriptionStore implements SubscriptionStore {
|
|
33
|
+
async findCurrentByUserId(userId: number): Promise<Subscription | null> {
|
|
34
|
+
const row = await SubscriptionModel.query()
|
|
35
|
+
.where('userId', userId)
|
|
36
|
+
.orderBy('currentPeriodEnd', 'desc')
|
|
37
|
+
.first()
|
|
38
|
+
return row ? toSubscription(row) : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async findActiveByUserId(
|
|
42
|
+
userId: number,
|
|
43
|
+
activeStatuses: string[]
|
|
44
|
+
): Promise<Subscription | null> {
|
|
45
|
+
const row = await SubscriptionModel.query()
|
|
46
|
+
.where('userId', userId)
|
|
47
|
+
.whereIn('status', activeStatuses)
|
|
48
|
+
.orderBy('currentPeriodEnd', 'desc')
|
|
49
|
+
.first()
|
|
50
|
+
return row ? toSubscription(row) : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async upsert(snapshot: SubscriptionSnapshot): Promise<void> {
|
|
54
|
+
await SubscriptionModel.updateOrCreate(
|
|
55
|
+
{ userId: snapshot.userId, creemSubscriptionId: snapshot.id },
|
|
56
|
+
{
|
|
57
|
+
userId: snapshot.userId,
|
|
58
|
+
creemCustomerId: snapshot.customerId,
|
|
59
|
+
creemSubscriptionId: snapshot.id,
|
|
60
|
+
creemProductId: snapshot.productId,
|
|
61
|
+
plan: snapshot.productName,
|
|
62
|
+
status: snapshot.status,
|
|
63
|
+
currentPeriodStart: period(snapshot.currentPeriodStart),
|
|
64
|
+
currentPeriodEnd: period(snapshot.currentPeriodEnd),
|
|
65
|
+
canceledAt: snapshot.canceledAt ? period(snapshot.canceledAt) : null,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Lucid factory for subscriptions. Copied to app/adapters/subscription.ts.
|
|
3
|
+
* Import as `#adapters/subscription`.
|
|
4
|
+
*/
|
|
5
|
+
import { SubscriptionService } from '#modules/subscription/service'
|
|
6
|
+
import { LucidSubscriptionStore } from './lucid_subscription_store.ts'
|
|
7
|
+
import type { CreemService } from '#modules/creem/service'
|
|
8
|
+
import type {
|
|
9
|
+
SubscriptionConfig,
|
|
10
|
+
SubscriptionStore,
|
|
11
|
+
} from '#modules/subscription/contracts/index'
|
|
12
|
+
|
|
13
|
+
export { LucidSubscriptionStore } from './lucid_subscription_store.ts'
|
|
14
|
+
|
|
15
|
+
export function createSubscriptionService(
|
|
16
|
+
creem: CreemService,
|
|
17
|
+
store: SubscriptionStore = new LucidSubscriptionStore(),
|
|
18
|
+
config: SubscriptionConfig = {}
|
|
19
|
+
) {
|
|
20
|
+
return new SubscriptionService(store, creem, config)
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
2
|
+
|
|
3
|
+
export default class extends BaseSchema {
|
|
4
|
+
protected tableName = 'subscriptions'
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
this.schema.createTable(this.tableName, (table) => {
|
|
8
|
+
table.increments('id').primary().notNullable()
|
|
9
|
+
table.integer('user_id').references('id').inTable('users').onDelete('CASCADE')
|
|
10
|
+
table.string('creem_customer_id').notNullable()
|
|
11
|
+
table.string('creem_subscription_id').unique()
|
|
12
|
+
table.string('creem_product_id').notNullable()
|
|
13
|
+
table.string('plan').notNullable()
|
|
14
|
+
table.string('status').notNullable()
|
|
15
|
+
table.timestamp('current_period_start')
|
|
16
|
+
table.timestamp('current_period_end')
|
|
17
|
+
table.timestamp('canceled_at')
|
|
18
|
+
table.timestamp('created_at')
|
|
19
|
+
table.timestamp('updated_at')
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async down() {
|
|
24
|
+
this.schema.dropTable(this.tableName)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
2
|
+
|
|
3
|
+
export default class extends BaseSchema {
|
|
4
|
+
protected tableName = 'subscriptions'
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
this.schema.alterTable(this.tableName, (table) => {
|
|
8
|
+
table.dropUnique(['creem_subscription_id'])
|
|
9
|
+
table.unique(['user_id', 'creem_subscription_id'])
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async down() {
|
|
14
|
+
this.schema.alterTable(this.tableName, (table) => {
|
|
15
|
+
table.dropUnique(['user_id', 'creem_subscription_id'])
|
|
16
|
+
table.unique(['creem_subscription_id'])
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
|
|
3
|
+
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
|
4
|
+
import User from '#models/user'
|
|
5
|
+
|
|
6
|
+
export default class Subscription extends BaseModel {
|
|
7
|
+
@column({ isPrimary: true })
|
|
8
|
+
declare id: number
|
|
9
|
+
|
|
10
|
+
@column()
|
|
11
|
+
declare userId: number
|
|
12
|
+
|
|
13
|
+
@column()
|
|
14
|
+
declare creemCustomerId: string
|
|
15
|
+
|
|
16
|
+
@column()
|
|
17
|
+
declare creemSubscriptionId: string | null
|
|
18
|
+
|
|
19
|
+
@column()
|
|
20
|
+
declare creemProductId: string
|
|
21
|
+
|
|
22
|
+
@column()
|
|
23
|
+
declare plan: string
|
|
24
|
+
|
|
25
|
+
@column()
|
|
26
|
+
declare status: string
|
|
27
|
+
|
|
28
|
+
@column.dateTime()
|
|
29
|
+
declare currentPeriodStart: DateTime | null
|
|
30
|
+
|
|
31
|
+
@column.dateTime()
|
|
32
|
+
declare currentPeriodEnd: DateTime | null
|
|
33
|
+
|
|
34
|
+
@column.dateTime()
|
|
35
|
+
declare canceledAt: DateTime | null
|
|
36
|
+
|
|
37
|
+
@column.dateTime({ autoCreate: true })
|
|
38
|
+
declare createdAt: DateTime
|
|
39
|
+
|
|
40
|
+
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
|
41
|
+
declare updatedAt: DateTime
|
|
42
|
+
|
|
43
|
+
@belongsTo(() => User)
|
|
44
|
+
declare user: BelongsTo<typeof User>
|
|
45
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CreemService } from '#modules/creem/service'
|
|
4
|
+
import { SubscriptionService } from '#modules/subscription/service'
|
|
5
|
+
import { FakeCreemClient, makeNormalizedSub } from '#modules/creem/tests/fakes/fake_creem_client'
|
|
6
|
+
import { MemorySubscriptionStore } from '#modules/subscription/tests/fakes/memory_subscription_store'
|
|
7
|
+
|
|
8
|
+
function makeBilling(productPlans: Record<string, string> = { prod_pro: 'pro' }) {
|
|
9
|
+
const client = new FakeCreemClient()
|
|
10
|
+
const creem = new CreemService(client, {
|
|
11
|
+
successUrl: 'https://app.test/success',
|
|
12
|
+
productPlans,
|
|
13
|
+
defaultPlan: 'starter',
|
|
14
|
+
})
|
|
15
|
+
const store = new MemorySubscriptionStore()
|
|
16
|
+
const subscriptions = new SubscriptionService(store, creem)
|
|
17
|
+
const emissions: Array<{ event: string; payload: any }> = []
|
|
18
|
+
;(creem as any).emitSafe = async () => {}
|
|
19
|
+
;(subscriptions as any).emitSafe = async (event: string, payload: any) => {
|
|
20
|
+
emissions.push({ event, payload })
|
|
21
|
+
}
|
|
22
|
+
return { client, creem, store, subscriptions, emissions }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test.group('CreemService - resolveProductPlan', () => {
|
|
26
|
+
test('maps known products and falls back to defaultPlan', async () => {
|
|
27
|
+
const { creem } = makeBilling({ prod_pro: 'pro' })
|
|
28
|
+
assert.equal(creem.resolveProductPlan('prod_pro'), 'pro')
|
|
29
|
+
assert.equal(creem.resolveProductPlan('prod_unknown'), 'starter')
|
|
30
|
+
assert.equal(creem.defaultPlan, 'starter')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test.group('SubscriptionService - getUserPlan', () => {
|
|
35
|
+
test('no subscription returns defaultPlan', async () => {
|
|
36
|
+
const { subscriptions } = makeBilling()
|
|
37
|
+
assert.equal(await subscriptions.getUserPlan(1), 'starter')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('inactive subscription returns defaultPlan', async () => {
|
|
41
|
+
const { subscriptions, store } = makeBilling()
|
|
42
|
+
await store.upsert({
|
|
43
|
+
id: 'sub_1',
|
|
44
|
+
userId: 1,
|
|
45
|
+
customerId: 'c',
|
|
46
|
+
productId: 'prod_pro',
|
|
47
|
+
productName: 'Pro',
|
|
48
|
+
status: 'canceled',
|
|
49
|
+
currentPeriodStart: new Date('2024-01-01'),
|
|
50
|
+
currentPeriodEnd: new Date('2024-02-01'),
|
|
51
|
+
canceledAt: new Date('2024-01-15'),
|
|
52
|
+
})
|
|
53
|
+
assert.equal(await subscriptions.getUserPlan(1), 'starter')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('active mapped product returns plan', async () => {
|
|
57
|
+
const { subscriptions, store } = makeBilling()
|
|
58
|
+
await store.upsert({
|
|
59
|
+
id: 'sub_1',
|
|
60
|
+
userId: 1,
|
|
61
|
+
customerId: 'c',
|
|
62
|
+
productId: 'prod_pro',
|
|
63
|
+
productName: 'Pro',
|
|
64
|
+
status: 'active',
|
|
65
|
+
currentPeriodStart: new Date('2024-01-01'),
|
|
66
|
+
currentPeriodEnd: new Date('2024-02-01'),
|
|
67
|
+
canceledAt: null,
|
|
68
|
+
})
|
|
69
|
+
assert.equal(await subscriptions.getUserPlan(1), 'pro')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('active unmapped product returns defaultPlan', async () => {
|
|
73
|
+
const { subscriptions, store } = makeBilling()
|
|
74
|
+
await store.upsert({
|
|
75
|
+
id: 'sub_1',
|
|
76
|
+
userId: 1,
|
|
77
|
+
customerId: 'c',
|
|
78
|
+
productId: 'prod_unknown',
|
|
79
|
+
productName: 'X',
|
|
80
|
+
status: 'active',
|
|
81
|
+
currentPeriodStart: new Date('2024-01-01'),
|
|
82
|
+
currentPeriodEnd: new Date('2024-02-01'),
|
|
83
|
+
canceledAt: null,
|
|
84
|
+
})
|
|
85
|
+
assert.equal(await subscriptions.getUserPlan(1), 'starter')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test.group('SubscriptionService - syncFromWebhook', () => {
|
|
90
|
+
test('payload without metadata.userId is ignored', async () => {
|
|
91
|
+
const { client, subscriptions, store, emissions } = makeBilling()
|
|
92
|
+
client.nextHandler = {
|
|
93
|
+
name: 'onGrantAccess',
|
|
94
|
+
payload: makeNormalizedSub({ metadata: {} }),
|
|
95
|
+
}
|
|
96
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
97
|
+
assert.equal(store.rows.length, 0)
|
|
98
|
+
assert.equal(emissions.length, 0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('grant upserts and emits Activated', async () => {
|
|
102
|
+
const { client, subscriptions, store, emissions } = makeBilling()
|
|
103
|
+
client.nextHandler = {
|
|
104
|
+
name: 'onGrantAccess',
|
|
105
|
+
payload: makeNormalizedSub({ status: 'active' }),
|
|
106
|
+
}
|
|
107
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
108
|
+
assert.equal(store.rows.length, 1)
|
|
109
|
+
assert.equal(emissions[0]?.event, 'Subscription:Activated')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('revoke, cancel, scheduled cancel, and update each upsert and emit', async () => {
|
|
113
|
+
const cases = [
|
|
114
|
+
['onRevokeAccess', 'Subscription:Revoked'],
|
|
115
|
+
['onSubscriptionCanceled', 'Subscription:Canceled'],
|
|
116
|
+
['onSubscriptionScheduledCancel', 'Subscription:ScheduledCancel'],
|
|
117
|
+
['onSubscriptionUpdate', 'Subscription:Updated'],
|
|
118
|
+
] as const
|
|
119
|
+
|
|
120
|
+
for (const [handler, event] of cases) {
|
|
121
|
+
const { client, subscriptions, store, emissions } = makeBilling()
|
|
122
|
+
client.nextHandler = {
|
|
123
|
+
name: handler,
|
|
124
|
+
payload: makeNormalizedSub({ status: 'canceled', id: `sub_${handler}` }),
|
|
125
|
+
}
|
|
126
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
127
|
+
assert.equal(store.rows.length, 1, handler)
|
|
128
|
+
assert.equal(emissions[0]?.event, event, handler)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('delivering the same webhook twice produces one row', async () => {
|
|
133
|
+
const { client, subscriptions, store } = makeBilling()
|
|
134
|
+
const payload = makeNormalizedSub()
|
|
135
|
+
client.nextHandler = { name: 'onGrantAccess', payload }
|
|
136
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
137
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
138
|
+
assert.equal(store.rows.length, 1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('update before grant_access produces one row in the updated state', async () => {
|
|
142
|
+
const { client, subscriptions, store } = makeBilling()
|
|
143
|
+
client.nextHandler = {
|
|
144
|
+
name: 'onSubscriptionUpdate',
|
|
145
|
+
payload: makeNormalizedSub({ status: 'active', product: { id: 'prod_pro', name: 'Pro' } }),
|
|
146
|
+
}
|
|
147
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
148
|
+
client.nextHandler = {
|
|
149
|
+
name: 'onGrantAccess',
|
|
150
|
+
payload: makeNormalizedSub({
|
|
151
|
+
status: 'trialing',
|
|
152
|
+
product: { id: 'prod_pro', name: 'Pro Trial' },
|
|
153
|
+
}),
|
|
154
|
+
}
|
|
155
|
+
await subscriptions.syncFromWebhook('body', 'sig')
|
|
156
|
+
assert.equal(store.rows.length, 1)
|
|
157
|
+
assert.equal(store.rows[0].status, 'trialing')
|
|
158
|
+
assert.equal(store.rows[0].plan, 'Pro Trial')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('canceled older row plus newer active resolves to active plan', async () => {
|
|
162
|
+
const { subscriptions, store } = makeBilling()
|
|
163
|
+
await store.upsert({
|
|
164
|
+
id: 'sub_old',
|
|
165
|
+
userId: 1,
|
|
166
|
+
customerId: 'c',
|
|
167
|
+
productId: 'prod_pro',
|
|
168
|
+
productName: 'Pro',
|
|
169
|
+
status: 'canceled',
|
|
170
|
+
currentPeriodStart: new Date('2023-01-01'),
|
|
171
|
+
currentPeriodEnd: new Date('2023-02-01'),
|
|
172
|
+
canceledAt: new Date('2023-01-15'),
|
|
173
|
+
})
|
|
174
|
+
await store.upsert({
|
|
175
|
+
id: 'sub_new',
|
|
176
|
+
userId: 1,
|
|
177
|
+
customerId: 'c',
|
|
178
|
+
productId: 'prod_pro',
|
|
179
|
+
productName: 'Pro',
|
|
180
|
+
status: 'active',
|
|
181
|
+
currentPeriodStart: new Date('2024-01-01'),
|
|
182
|
+
currentPeriodEnd: new Date('2024-02-01'),
|
|
183
|
+
canceledAt: null,
|
|
184
|
+
})
|
|
185
|
+
assert.equal(await subscriptions.getUserPlan(1), 'pro')
|
|
186
|
+
const current = await subscriptions.getSubscription(1)
|
|
187
|
+
assert.equal(current?.creemSubscriptionId, 'sub_new')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Subscription,
|
|
3
|
+
SubscriptionSnapshot,
|
|
4
|
+
SubscriptionStore,
|
|
5
|
+
} from '#modules/subscription/contracts/index'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-memory SubscriptionStore. upsert keys on (userId, creemSubscriptionId).
|
|
9
|
+
*/
|
|
10
|
+
export class MemorySubscriptionStore implements SubscriptionStore {
|
|
11
|
+
rows: Subscription[] = []
|
|
12
|
+
upserts: SubscriptionSnapshot[] = []
|
|
13
|
+
nextId = 1
|
|
14
|
+
|
|
15
|
+
async findCurrentByUserId(userId: number): Promise<Subscription | null> {
|
|
16
|
+
const matches = this.rows
|
|
17
|
+
.filter((row) => row.userId === userId)
|
|
18
|
+
.sort((a, b) => {
|
|
19
|
+
const aEnd = a.currentPeriodEnd?.getTime() ?? 0
|
|
20
|
+
const bEnd = b.currentPeriodEnd?.getTime() ?? 0
|
|
21
|
+
return bEnd - aEnd
|
|
22
|
+
})
|
|
23
|
+
return matches[0] ?? null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async findActiveByUserId(
|
|
27
|
+
userId: number,
|
|
28
|
+
activeStatuses: string[]
|
|
29
|
+
): Promise<Subscription | null> {
|
|
30
|
+
const matches = this.rows
|
|
31
|
+
.filter((row) => row.userId === userId && activeStatuses.includes(row.status))
|
|
32
|
+
.sort((a, b) => {
|
|
33
|
+
const aEnd = a.currentPeriodEnd?.getTime() ?? 0
|
|
34
|
+
const bEnd = b.currentPeriodEnd?.getTime() ?? 0
|
|
35
|
+
return bEnd - aEnd
|
|
36
|
+
})
|
|
37
|
+
return matches[0] ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async upsert(snapshot: SubscriptionSnapshot): Promise<void> {
|
|
41
|
+
this.upserts.push(snapshot)
|
|
42
|
+
const index = this.rows.findIndex(
|
|
43
|
+
(row) => row.userId === snapshot.userId && row.creemSubscriptionId === snapshot.id
|
|
44
|
+
)
|
|
45
|
+
const next: Subscription = {
|
|
46
|
+
id: index >= 0 ? this.rows[index].id : this.nextId++,
|
|
47
|
+
userId: snapshot.userId,
|
|
48
|
+
creemCustomerId: snapshot.customerId,
|
|
49
|
+
creemSubscriptionId: snapshot.id,
|
|
50
|
+
creemProductId: snapshot.productId,
|
|
51
|
+
plan: snapshot.productName,
|
|
52
|
+
status: snapshot.status,
|
|
53
|
+
currentPeriodStart: snapshot.currentPeriodStart,
|
|
54
|
+
currentPeriodEnd: snapshot.currentPeriodEnd,
|
|
55
|
+
canceledAt: snapshot.canceledAt,
|
|
56
|
+
}
|
|
57
|
+
if (index >= 0) {
|
|
58
|
+
this.rows[index] = next
|
|
59
|
+
} else {
|
|
60
|
+
this.rows.push(next)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared API response envelope types for the starter core.
|
|
3
|
+
* Runtime helpers live in modules/api/envelope.ts.
|
|
4
|
+
*
|
|
5
|
+
* Controllers (and hosts) build these envelopes. Domain services return DTOs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PaginationMeta {
|
|
9
|
+
total: number
|
|
10
|
+
page: number
|
|
11
|
+
pageSize: number
|
|
12
|
+
totalPages: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ApiResponse<T> {
|
|
16
|
+
data: T
|
|
17
|
+
message: string | null
|
|
18
|
+
success: boolean
|
|
19
|
+
meta?: PaginationMeta
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ApiErrorBody = {
|
|
23
|
+
success: false
|
|
24
|
+
message: string
|
|
25
|
+
status?: number
|
|
26
|
+
errors?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ApiResult<T> =
|
|
30
|
+
| { success: true; data: T; message: string | null; meta?: PaginationMeta }
|
|
31
|
+
| ApiErrorBody
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Paginated success envelope — same shape as ApiResponse with required meta.
|
|
35
|
+
*/
|
|
36
|
+
export type PaginatedResponse<T> = ApiResponse<T[]> & { meta: PaginationMeta }
|
|
37
|
+
|
|
38
|
+
export interface PaginationParams {
|
|
39
|
+
page?: number
|
|
40
|
+
pageSize?: number
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
|
|
3
|
+
export type ModuleActionConfig = {
|
|
4
|
+
description: string
|
|
5
|
+
/** Event names enabled for emitSafe. Filled from each module's events list. */
|
|
6
|
+
emits: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type NameSpace = null | string
|
|
10
|
+
|
|
11
|
+
export type ModularModel<T> = {
|
|
12
|
+
nameSpace: NameSpace
|
|
13
|
+
} & T
|
|
14
|
+
|
|
15
|
+
export interface IUserModel {
|
|
16
|
+
id: number
|
|
17
|
+
authMethod: 'github' | 'google' | 'email'
|
|
18
|
+
email: string
|
|
19
|
+
pendingEmail: string | null
|
|
20
|
+
firstName: string
|
|
21
|
+
lastName: string
|
|
22
|
+
avatarKey: string | null
|
|
23
|
+
password: string | null
|
|
24
|
+
emailVerificationToken: string | null
|
|
25
|
+
createdAt: DateTime
|
|
26
|
+
updatedAt: DateTime | null
|
|
27
|
+
emailVerifiedAt: DateTime | null
|
|
28
|
+
verifyEmail(): void
|
|
29
|
+
generateVerificationToken(): void
|
|
30
|
+
get name(): string
|
|
31
|
+
get avatar(): string
|
|
32
|
+
}
|