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.
Files changed (140) hide show
  1. package/README.md +64 -0
  2. package/build/adonis_peers.d.ts +14 -0
  3. package/build/adonis_peers.js +56 -0
  4. package/build/cli.d.ts +2 -0
  5. package/build/cli.js +111 -0
  6. package/build/commands/add.d.ts +18 -0
  7. package/build/commands/add.js +246 -0
  8. package/build/commands/check.d.ts +4 -0
  9. package/build/commands/check.js +159 -0
  10. package/build/commands/diff.d.ts +2 -0
  11. package/build/commands/diff.js +74 -0
  12. package/build/commands/init.d.ts +6 -0
  13. package/build/commands/init.js +124 -0
  14. package/build/commands/list.d.ts +2 -0
  15. package/build/commands/list.js +34 -0
  16. package/build/config_merge.d.ts +10 -0
  17. package/build/config_merge.js +71 -0
  18. package/build/constants_merge.d.ts +4 -0
  19. package/build/constants_merge.js +85 -0
  20. package/build/copy.d.ts +8 -0
  21. package/build/copy.js +33 -0
  22. package/build/host.d.ts +15 -0
  23. package/build/host.js +85 -0
  24. package/build/registry/index.d.ts +26 -0
  25. package/build/registry/index.js +116 -0
  26. package/build/registry/plan.d.ts +53 -0
  27. package/build/registry/plan.js +159 -0
  28. package/build/schema.d.ts +136 -0
  29. package/build/schema.js +58 -0
  30. package/build/types.d.ts +21 -0
  31. package/build/types.js +1 -0
  32. package/build/wire.d.ts +16 -0
  33. package/build/wire.js +162 -0
  34. package/package.json +51 -0
  35. package/registry/README.md +86 -0
  36. package/registry/account/contracts/avatar_storage.ts +7 -0
  37. package/registry/account/contracts/index.ts +7 -0
  38. package/registry/account/contracts/types.ts +23 -0
  39. package/registry/account/events.ts +14 -0
  40. package/registry/account/module.json +31 -0
  41. package/registry/account/options.ts +11 -0
  42. package/registry/account/service.ts +155 -0
  43. package/registry/account/stubs/adapters/account.ts +21 -0
  44. package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
  45. package/registry/account/stubs/controllers/account_controller.ts +190 -0
  46. package/registry/account/stubs/routes/account.ts +53 -0
  47. package/registry/account/stubs/validators/account.ts +37 -0
  48. package/registry/account/tests/account_service.spec.ts +132 -0
  49. package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
  50. package/registry/api/envelope.ts +66 -0
  51. package/registry/api/exception.ts +38 -0
  52. package/registry/api/exception_handler.ts +54 -0
  53. package/registry/api/module.json +27 -0
  54. package/registry/api/provider.ts +129 -0
  55. package/registry/api/service.ts +88 -0
  56. package/registry/api/tests/emit_safe.spec.ts +107 -0
  57. package/registry/api/tests/respond.spec.ts +40 -0
  58. package/registry/auth/contracts/index.ts +18 -0
  59. package/registry/auth/contracts/password_reset_store.ts +21 -0
  60. package/registry/auth/contracts/session_manager.ts +19 -0
  61. package/registry/auth/contracts/types.ts +32 -0
  62. package/registry/auth/events.ts +21 -0
  63. package/registry/auth/module.json +45 -0
  64. package/registry/auth/options.ts +23 -0
  65. package/registry/auth/service.ts +150 -0
  66. package/registry/auth/stubs/adapters/auth.ts +21 -0
  67. package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
  68. package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
  69. package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
  70. package/registry/auth/stubs/adapters/map_user.ts +19 -0
  71. package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
  72. package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
  73. package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
  74. package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
  75. package/registry/auth/stubs/models/password_reset.ts +29 -0
  76. package/registry/auth/stubs/models/user.ts +99 -0
  77. package/registry/auth/stubs/providers/vine_provider.ts +43 -0
  78. package/registry/auth/stubs/routes/auth.ts +36 -0
  79. package/registry/auth/stubs/start/limiter.ts +17 -0
  80. package/registry/auth/stubs/validators/auth.ts +48 -0
  81. package/registry/auth/stubs/validators/rules/exists.ts +21 -0
  82. package/registry/auth/stubs/validators/rules/unique.ts +21 -0
  83. package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
  84. package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
  85. package/registry/auth/stubs/validators/vine.d.ts +10 -0
  86. package/registry/auth/tests/auth_service.spec.ts +145 -0
  87. package/registry/auth/tests/auth_update_password.spec.ts +99 -0
  88. package/registry/auth/tests/fakes/index.ts +3 -0
  89. package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
  90. package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
  91. package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
  92. package/registry/constants/exceptions.ts +64 -0
  93. package/registry/constants/index.ts +12 -0
  94. package/registry/constants/responseCodes.ts +87 -0
  95. package/registry/contracts/index.ts +5 -0
  96. package/registry/contracts/user.ts +78 -0
  97. package/registry/creem/contracts/creem_client.ts +34 -0
  98. package/registry/creem/contracts/index.ts +8 -0
  99. package/registry/creem/contracts/types.ts +30 -0
  100. package/registry/creem/events.ts +9 -0
  101. package/registry/creem/module.json +28 -0
  102. package/registry/creem/service.ts +87 -0
  103. package/registry/creem/stubs/adapters/creem.ts +23 -0
  104. package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
  105. package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
  106. package/registry/notification/contracts/app_notification_store.ts +8 -0
  107. package/registry/notification/contracts/index.ts +9 -0
  108. package/registry/notification/contracts/mail_transport.ts +7 -0
  109. package/registry/notification/contracts/types.ts +32 -0
  110. package/registry/notification/module.json +23 -0
  111. package/registry/notification/options.ts +11 -0
  112. package/registry/notification/service.ts +103 -0
  113. package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
  114. package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
  115. package/registry/notification/stubs/adapters/notification.ts +36 -0
  116. package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
  117. package/registry/notification/stubs/models/app_notification.ts +45 -0
  118. package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
  119. package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
  120. package/registry/notification/tests/notification_service.spec.ts +63 -0
  121. package/registry/registry.json +4 -0
  122. package/registry/subscription/contracts/index.ts +9 -0
  123. package/registry/subscription/contracts/subscription_store.ts +15 -0
  124. package/registry/subscription/contracts/types.ts +39 -0
  125. package/registry/subscription/events.ts +13 -0
  126. package/registry/subscription/module.json +31 -0
  127. package/registry/subscription/options.ts +14 -0
  128. package/registry/subscription/service.ts +140 -0
  129. package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
  130. package/registry/subscription/stubs/adapters/subscription.ts +21 -0
  131. package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
  132. package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
  133. package/registry/subscription/stubs/models/subscription.ts +45 -0
  134. package/registry/subscription/tests/billing_service.spec.ts +189 -0
  135. package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
  136. package/registry/types/api.ts +41 -0
  137. package/registry/types/constants.ts +7 -0
  138. package/registry/types/index.ts +6 -0
  139. package/registry/types/models.ts +32 -0
  140. 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,7 @@
1
+ /**
2
+ * Shared shape for exception and success response code constants.
3
+ */
4
+ export type StatusCodeEntry = {
5
+ status: number
6
+ code: string
7
+ }
@@ -0,0 +1,6 @@
1
+ import * as ModelsTypes from './models.ts'
2
+ import * as ApiTypes from './api.ts'
3
+ import type { StatusCodeEntry } from './constants.ts'
4
+
5
+ export type { StatusCodeEntry }
6
+ export { ModelsTypes, ApiTypes }
@@ -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
+ }