servcraft 0.1.0 → 0.1.1
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/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { logger } from '../../core/logger.js';
|
|
2
|
+
import { NotFoundError, BadRequestError } from '../../utils/errors.js';
|
|
3
|
+
import type {
|
|
4
|
+
Payment,
|
|
5
|
+
PaymentIntent,
|
|
6
|
+
CreatePaymentData,
|
|
7
|
+
PaymentProvider,
|
|
8
|
+
StripeConfig,
|
|
9
|
+
PayPalConfig,
|
|
10
|
+
MobileMoneyConfig,
|
|
11
|
+
Subscription,
|
|
12
|
+
Plan,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
import { StripeProvider } from './providers/stripe.provider.js';
|
|
15
|
+
import { PayPalProvider } from './providers/paypal.provider.js';
|
|
16
|
+
import { MobileMoneyProvider } from './providers/mobile-money.provider.js';
|
|
17
|
+
import type { PaymentRepository } from './payment.repository.js';
|
|
18
|
+
import { createPaymentRepository } from './payment.repository.js';
|
|
19
|
+
|
|
20
|
+
export interface PaymentServiceConfig {
|
|
21
|
+
stripe?: StripeConfig;
|
|
22
|
+
paypal?: PayPalConfig;
|
|
23
|
+
mobileMoney?: MobileMoneyConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PaymentService {
|
|
27
|
+
private stripeProvider?: StripeProvider;
|
|
28
|
+
private paypalProvider?: PayPalProvider;
|
|
29
|
+
private mobileMoneyProvider?: MobileMoneyProvider;
|
|
30
|
+
private repository: PaymentRepository;
|
|
31
|
+
|
|
32
|
+
constructor(config: PaymentServiceConfig = {}) {
|
|
33
|
+
this.repository = createPaymentRepository();
|
|
34
|
+
|
|
35
|
+
if (config.stripe) {
|
|
36
|
+
this.stripeProvider = new StripeProvider(config.stripe);
|
|
37
|
+
}
|
|
38
|
+
if (config.paypal) {
|
|
39
|
+
this.paypalProvider = new PayPalProvider(config.paypal);
|
|
40
|
+
}
|
|
41
|
+
if (config.mobileMoney) {
|
|
42
|
+
this.mobileMoneyProvider = new MobileMoneyProvider(config.mobileMoney);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Payment methods
|
|
47
|
+
async createPayment(data: CreatePaymentData): Promise<PaymentIntent> {
|
|
48
|
+
let providerPaymentId: string | undefined;
|
|
49
|
+
let intent: PaymentIntent;
|
|
50
|
+
|
|
51
|
+
switch (data.provider) {
|
|
52
|
+
case 'stripe':
|
|
53
|
+
if (!this.stripeProvider) {
|
|
54
|
+
throw new BadRequestError('Stripe not configured');
|
|
55
|
+
}
|
|
56
|
+
intent = await this.stripeProvider.createPaymentIntent({
|
|
57
|
+
amount: data.amount,
|
|
58
|
+
currency: data.currency,
|
|
59
|
+
description: data.description,
|
|
60
|
+
metadata: data.metadata as Record<string, string>,
|
|
61
|
+
});
|
|
62
|
+
providerPaymentId = intent.id;
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'paypal':
|
|
66
|
+
if (!this.paypalProvider) {
|
|
67
|
+
throw new BadRequestError('PayPal not configured');
|
|
68
|
+
}
|
|
69
|
+
intent = await this.paypalProvider.createOrder({
|
|
70
|
+
amount: data.amount,
|
|
71
|
+
currency: data.currency,
|
|
72
|
+
description: data.description,
|
|
73
|
+
returnUrl: data.returnUrl || '',
|
|
74
|
+
cancelUrl: data.cancelUrl || '',
|
|
75
|
+
});
|
|
76
|
+
providerPaymentId = intent.id;
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case 'mobile_money': {
|
|
80
|
+
if (!this.mobileMoneyProvider) {
|
|
81
|
+
throw new BadRequestError('Mobile Money not configured');
|
|
82
|
+
}
|
|
83
|
+
const phoneNumber = data.metadata?.phoneNumber as string;
|
|
84
|
+
if (!phoneNumber) {
|
|
85
|
+
throw new BadRequestError('Phone number required for Mobile Money');
|
|
86
|
+
}
|
|
87
|
+
intent = await this.mobileMoneyProvider.initiatePayment({
|
|
88
|
+
amount: data.amount,
|
|
89
|
+
currency: data.currency,
|
|
90
|
+
phoneNumber,
|
|
91
|
+
description: data.description,
|
|
92
|
+
});
|
|
93
|
+
providerPaymentId = intent.id;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
default:
|
|
98
|
+
intent = {
|
|
99
|
+
id: '', // Will be set after payment creation
|
|
100
|
+
status: 'pending',
|
|
101
|
+
provider: 'manual',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Create payment in database
|
|
106
|
+
const payment = await this.repository.createPayment({
|
|
107
|
+
userId: data.userId,
|
|
108
|
+
provider: data.provider,
|
|
109
|
+
method: data.method || 'card',
|
|
110
|
+
amount: data.amount,
|
|
111
|
+
currency: data.currency,
|
|
112
|
+
description: data.description,
|
|
113
|
+
metadata: data.metadata,
|
|
114
|
+
providerPaymentId,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
logger.info({ paymentId: payment.id, provider: data.provider }, 'Payment created');
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...intent,
|
|
121
|
+
id: payment.id,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async confirmPayment(paymentId: string): Promise<Payment> {
|
|
126
|
+
const payment = await this.repository.findPaymentById(paymentId);
|
|
127
|
+
if (!payment) {
|
|
128
|
+
throw new NotFoundError('Payment');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (payment.provider === 'stripe' && this.stripeProvider && payment.providerPaymentId) {
|
|
132
|
+
await this.stripeProvider.confirmPayment(payment.providerPaymentId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const updatedPayment = await this.repository.updatePaymentStatus(paymentId, 'completed', {
|
|
136
|
+
paidAt: new Date(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!updatedPayment) {
|
|
140
|
+
throw new NotFoundError('Payment');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
logger.info({ paymentId }, 'Payment confirmed');
|
|
144
|
+
return updatedPayment;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async refundPayment(paymentId: string, amount?: number): Promise<Payment> {
|
|
148
|
+
const payment = await this.repository.findPaymentById(paymentId);
|
|
149
|
+
if (!payment) {
|
|
150
|
+
throw new NotFoundError('Payment');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (payment.status !== 'completed') {
|
|
154
|
+
throw new BadRequestError('Payment cannot be refunded');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const refundAmount = amount || payment.amount;
|
|
158
|
+
|
|
159
|
+
if (payment.provider === 'stripe' && this.stripeProvider && payment.providerPaymentId) {
|
|
160
|
+
await this.stripeProvider.refundPayment(payment.providerPaymentId, refundAmount);
|
|
161
|
+
} else if (payment.provider === 'paypal' && this.paypalProvider && payment.providerPaymentId) {
|
|
162
|
+
await this.paypalProvider.refundPayment(
|
|
163
|
+
payment.providerPaymentId,
|
|
164
|
+
refundAmount,
|
|
165
|
+
payment.currency
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const updatedPayment = await this.repository.updatePaymentStatus(paymentId, 'refunded', {
|
|
170
|
+
refundedAmount: refundAmount,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!updatedPayment) {
|
|
174
|
+
throw new NotFoundError('Payment');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
logger.info({ paymentId, refundAmount }, 'Payment refunded');
|
|
178
|
+
return updatedPayment;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async getPayment(paymentId: string): Promise<Payment | null> {
|
|
182
|
+
return this.repository.findPaymentById(paymentId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async getUserPayments(userId: string): Promise<Payment[]> {
|
|
186
|
+
const result = await this.repository.findUserPayments(userId, { page: 1, limit: 100 });
|
|
187
|
+
return result.data;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Subscription methods
|
|
191
|
+
async createSubscription(
|
|
192
|
+
userId: string,
|
|
193
|
+
planId: string,
|
|
194
|
+
provider: PaymentProvider = 'stripe'
|
|
195
|
+
): Promise<Subscription> {
|
|
196
|
+
const plan = await this.repository.findPlanById(planId);
|
|
197
|
+
if (!plan) {
|
|
198
|
+
throw new NotFoundError('Plan');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const currentPeriodStart = new Date();
|
|
202
|
+
const currentPeriodEnd = this.calculatePeriodEnd(plan);
|
|
203
|
+
|
|
204
|
+
const subscription = await this.repository.createSubscription({
|
|
205
|
+
userId,
|
|
206
|
+
planId,
|
|
207
|
+
provider,
|
|
208
|
+
currentPeriodStart,
|
|
209
|
+
currentPeriodEnd,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
logger.info({ subscriptionId: subscription.id, planId }, 'Subscription created');
|
|
213
|
+
return subscription;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async cancelSubscription(subscriptionId: string): Promise<Subscription> {
|
|
217
|
+
const subscription = await this.repository.cancelSubscription(subscriptionId);
|
|
218
|
+
if (!subscription) {
|
|
219
|
+
throw new NotFoundError('Subscription');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
logger.info({ subscriptionId }, 'Subscription cancelled');
|
|
223
|
+
return subscription;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getSubscription(subscriptionId: string): Promise<Subscription | null> {
|
|
227
|
+
return this.repository.findSubscriptionById(subscriptionId);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async getUserSubscriptions(userId: string): Promise<Subscription[]> {
|
|
231
|
+
return this.repository.findUserSubscriptions(userId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Plan methods
|
|
235
|
+
async createPlan(planData: Omit<Plan, 'id'>): Promise<Plan> {
|
|
236
|
+
const plan = await this.repository.createPlan({
|
|
237
|
+
name: planData.name,
|
|
238
|
+
description: planData.description,
|
|
239
|
+
amount: planData.amount,
|
|
240
|
+
currency: planData.currency,
|
|
241
|
+
interval: planData.interval,
|
|
242
|
+
intervalCount: planData.intervalCount,
|
|
243
|
+
trialDays: planData.trialDays,
|
|
244
|
+
features: planData.features,
|
|
245
|
+
metadata: planData.metadata,
|
|
246
|
+
active: planData.active,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
logger.info({ planId: plan.id }, 'Plan created');
|
|
250
|
+
return plan;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async getPlans(): Promise<Plan[]> {
|
|
254
|
+
return this.repository.findActivePlans();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getPlan(planId: string): Promise<Plan | null> {
|
|
258
|
+
return this.repository.findPlanById(planId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Webhook handling
|
|
262
|
+
async handleWebhook(
|
|
263
|
+
provider: PaymentProvider,
|
|
264
|
+
payload: string,
|
|
265
|
+
signature: string
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
logger.info({ provider }, 'Processing payment webhook');
|
|
268
|
+
|
|
269
|
+
switch (provider) {
|
|
270
|
+
case 'stripe':
|
|
271
|
+
if (this.stripeProvider) {
|
|
272
|
+
const event = this.stripeProvider.verifyWebhookSignature(payload, signature);
|
|
273
|
+
if (event) {
|
|
274
|
+
await this.processStripeEvent(event);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
break;
|
|
278
|
+
case 'paypal':
|
|
279
|
+
// Handle PayPal webhook
|
|
280
|
+
break;
|
|
281
|
+
case 'mobile_money':
|
|
282
|
+
// Handle Mobile Money callback
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async processStripeEvent(event: Record<string, unknown>): Promise<void> {
|
|
288
|
+
const type = event.type as string;
|
|
289
|
+
const data = event.data as { object: Record<string, unknown> };
|
|
290
|
+
const providerPaymentId = data.object.id as string;
|
|
291
|
+
|
|
292
|
+
// Store webhook event
|
|
293
|
+
await this.repository.storeWebhookEvent({
|
|
294
|
+
provider: 'stripe',
|
|
295
|
+
type,
|
|
296
|
+
data: data.object,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Find payment by provider payment ID
|
|
300
|
+
const payment = await this.repository.findPaymentByProviderPaymentId(providerPaymentId);
|
|
301
|
+
if (!payment) {
|
|
302
|
+
logger.warn({ providerPaymentId, type }, 'Payment not found for webhook event');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
switch (type) {
|
|
307
|
+
case 'payment_intent.succeeded':
|
|
308
|
+
await this.repository.updatePaymentStatus(payment.id, 'completed', {
|
|
309
|
+
paidAt: new Date(),
|
|
310
|
+
});
|
|
311
|
+
logger.info({ paymentId: payment.id }, 'Payment completed via webhook');
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
case 'payment_intent.payment_failed': {
|
|
315
|
+
const failureReason = (data.object.last_payment_error as { message?: string })?.message;
|
|
316
|
+
await this.repository.updatePaymentStatus(payment.id, 'failed', {
|
|
317
|
+
failureReason,
|
|
318
|
+
});
|
|
319
|
+
logger.info({ paymentId: payment.id, failureReason }, 'Payment failed via webhook');
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private calculatePeriodEnd(plan: Plan): Date {
|
|
326
|
+
const now = new Date();
|
|
327
|
+
switch (plan.interval) {
|
|
328
|
+
case 'day':
|
|
329
|
+
return new Date(now.setDate(now.getDate() + plan.intervalCount));
|
|
330
|
+
case 'week':
|
|
331
|
+
return new Date(now.setDate(now.getDate() + 7 * plan.intervalCount));
|
|
332
|
+
case 'month':
|
|
333
|
+
return new Date(now.setMonth(now.getMonth() + plan.intervalCount));
|
|
334
|
+
case 'year':
|
|
335
|
+
return new Date(now.setFullYear(now.getFullYear() + plan.intervalCount));
|
|
336
|
+
default:
|
|
337
|
+
return new Date(now.setMonth(now.getMonth() + 1));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let paymentService: PaymentService | null = null;
|
|
343
|
+
|
|
344
|
+
export function getPaymentService(): PaymentService {
|
|
345
|
+
if (!paymentService) {
|
|
346
|
+
paymentService = new PaymentService();
|
|
347
|
+
}
|
|
348
|
+
return paymentService;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function createPaymentService(config: PaymentServiceConfig): PaymentService {
|
|
352
|
+
paymentService = new PaymentService(config);
|
|
353
|
+
return paymentService;
|
|
354
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { logger } from '../../../core/logger.js';
|
|
2
|
+
import type {
|
|
3
|
+
MobileMoneyConfig,
|
|
4
|
+
MobileMoneyPaymentData,
|
|
5
|
+
PaymentIntent,
|
|
6
|
+
Payment,
|
|
7
|
+
} from '../types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mobile Money Payment Provider
|
|
11
|
+
* Supports MTN MoMo, Orange Money, Wave, M-Pesa
|
|
12
|
+
*/
|
|
13
|
+
export class MobileMoneyProvider {
|
|
14
|
+
private config: MobileMoneyConfig;
|
|
15
|
+
|
|
16
|
+
constructor(config: MobileMoneyConfig) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
logger.info({ provider: config.provider }, 'Mobile Money provider initialized');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private get baseUrl(): string {
|
|
22
|
+
const urls: Record<string, Record<string, string>> = {
|
|
23
|
+
mtn: {
|
|
24
|
+
sandbox: 'https://sandbox.momodeveloper.mtn.com',
|
|
25
|
+
production: 'https://momodeveloper.mtn.com',
|
|
26
|
+
},
|
|
27
|
+
orange: {
|
|
28
|
+
sandbox: 'https://api.sandbox.orange.com',
|
|
29
|
+
production: 'https://api.orange.com',
|
|
30
|
+
},
|
|
31
|
+
wave: {
|
|
32
|
+
sandbox: 'https://api.sandbox.wave.com',
|
|
33
|
+
production: 'https://api.wave.com',
|
|
34
|
+
},
|
|
35
|
+
mpesa: {
|
|
36
|
+
sandbox: 'https://sandbox.safaricom.co.ke',
|
|
37
|
+
production: 'https://api.safaricom.co.ke',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return urls[this.config.provider]?.[this.config.environment] || '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async initiatePayment(data: MobileMoneyPaymentData): Promise<PaymentIntent> {
|
|
45
|
+
switch (this.config.provider) {
|
|
46
|
+
case 'mtn':
|
|
47
|
+
return this.initiateMTNPayment(data);
|
|
48
|
+
case 'orange':
|
|
49
|
+
return this.initiateOrangePayment(data);
|
|
50
|
+
case 'wave':
|
|
51
|
+
return this.initiateWavePayment(data);
|
|
52
|
+
case 'mpesa':
|
|
53
|
+
return this.initiateMpesaPayment(data);
|
|
54
|
+
default:
|
|
55
|
+
throw new Error(`Unsupported provider: ${this.config.provider}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async initiateMTNPayment(data: MobileMoneyPaymentData): Promise<PaymentIntent> {
|
|
60
|
+
// MTN MoMo Collection API
|
|
61
|
+
const referenceId = crypto.randomUUID();
|
|
62
|
+
|
|
63
|
+
const response = await fetch(`${this.baseUrl}/collection/v1_0/requesttopay`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${await this.getAccessToken()}`,
|
|
67
|
+
'X-Reference-Id': referenceId,
|
|
68
|
+
'X-Target-Environment': this.config.environment,
|
|
69
|
+
'Ocp-Apim-Subscription-Key': this.config.apiKey,
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
amount: data.amount.toString(),
|
|
74
|
+
currency: data.currency,
|
|
75
|
+
externalId: data.reference || referenceId,
|
|
76
|
+
payer: {
|
|
77
|
+
partyIdType: 'MSISDN',
|
|
78
|
+
partyId: data.phoneNumber.replace('+', ''),
|
|
79
|
+
},
|
|
80
|
+
payerMessage: data.description || 'Payment',
|
|
81
|
+
payeeNote: data.description || 'Payment',
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const error = await response.text();
|
|
87
|
+
logger.error({ error }, 'MTN MoMo payment initiation failed');
|
|
88
|
+
throw new Error('Failed to initiate MTN payment');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: referenceId,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
provider: 'mobile_money',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async initiateOrangePayment(data: MobileMoneyPaymentData): Promise<PaymentIntent> {
|
|
99
|
+
// Orange Money API
|
|
100
|
+
const response = await fetch(`${this.baseUrl}/orange-money-webpay/dev/v1/webpayment`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${await this.getAccessToken()}`,
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
merchant_key: this.config.apiKey,
|
|
108
|
+
currency: data.currency,
|
|
109
|
+
order_id: data.reference || crypto.randomUUID(),
|
|
110
|
+
amount: data.amount,
|
|
111
|
+
return_url: this.config.callbackUrl,
|
|
112
|
+
cancel_url: this.config.callbackUrl,
|
|
113
|
+
notif_url: this.config.callbackUrl,
|
|
114
|
+
lang: 'fr',
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw new Error('Failed to initiate Orange Money payment');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = (await response.json()) as { payment_url: string; pay_token: string };
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: result.pay_token,
|
|
126
|
+
paymentUrl: result.payment_url,
|
|
127
|
+
status: 'pending',
|
|
128
|
+
provider: 'mobile_money',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async initiateWavePayment(data: MobileMoneyPaymentData): Promise<PaymentIntent> {
|
|
133
|
+
// Wave API
|
|
134
|
+
const response = await fetch(`${this.baseUrl}/v1/checkout/sessions`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${this.config.apiSecret}`,
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
amount: data.amount.toString(),
|
|
142
|
+
currency: data.currency,
|
|
143
|
+
error_url: this.config.callbackUrl,
|
|
144
|
+
success_url: this.config.callbackUrl,
|
|
145
|
+
client_reference: data.reference,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error('Failed to initiate Wave payment');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = (await response.json()) as { id: string; wave_launch_url: string };
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id: result.id,
|
|
157
|
+
paymentUrl: result.wave_launch_url,
|
|
158
|
+
status: 'pending',
|
|
159
|
+
provider: 'mobile_money',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async initiateMpesaPayment(data: MobileMoneyPaymentData): Promise<PaymentIntent> {
|
|
164
|
+
// M-Pesa STK Push
|
|
165
|
+
const timestamp = new Date()
|
|
166
|
+
.toISOString()
|
|
167
|
+
.replace(/[-:T.Z]/g, '')
|
|
168
|
+
.slice(0, 14);
|
|
169
|
+
const password = Buffer.from(
|
|
170
|
+
`${this.config.apiKey}${this.config.apiSecret}${timestamp}`
|
|
171
|
+
).toString('base64');
|
|
172
|
+
|
|
173
|
+
const response = await fetch(`${this.baseUrl}/mpesa/stkpush/v1/processrequest`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${await this.getAccessToken()}`,
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
BusinessShortCode: this.config.apiKey,
|
|
181
|
+
Password: password,
|
|
182
|
+
Timestamp: timestamp,
|
|
183
|
+
TransactionType: 'CustomerPayBillOnline',
|
|
184
|
+
Amount: Math.round(data.amount),
|
|
185
|
+
PartyA: data.phoneNumber.replace('+', ''),
|
|
186
|
+
PartyB: this.config.apiKey,
|
|
187
|
+
PhoneNumber: data.phoneNumber.replace('+', ''),
|
|
188
|
+
CallBackURL: this.config.callbackUrl,
|
|
189
|
+
AccountReference: data.reference || 'Payment',
|
|
190
|
+
TransactionDesc: data.description || 'Payment',
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
throw new Error('Failed to initiate M-Pesa payment');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result = (await response.json()) as { CheckoutRequestID: string };
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
id: result.CheckoutRequestID,
|
|
202
|
+
status: 'pending',
|
|
203
|
+
provider: 'mobile_money',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async checkPaymentStatus(paymentId: string): Promise<Payment['status']> {
|
|
208
|
+
switch (this.config.provider) {
|
|
209
|
+
case 'mtn':
|
|
210
|
+
return this.checkMTNStatus(paymentId);
|
|
211
|
+
case 'mpesa':
|
|
212
|
+
return this.checkMpesaStatus(paymentId);
|
|
213
|
+
default:
|
|
214
|
+
return 'pending';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async checkMTNStatus(referenceId: string): Promise<Payment['status']> {
|
|
219
|
+
const response = await fetch(`${this.baseUrl}/collection/v1_0/requesttopay/${referenceId}`, {
|
|
220
|
+
headers: {
|
|
221
|
+
Authorization: `Bearer ${await this.getAccessToken()}`,
|
|
222
|
+
'X-Target-Environment': this.config.environment,
|
|
223
|
+
'Ocp-Apim-Subscription-Key': this.config.apiKey,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
return 'pending';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = (await response.json()) as { status: string };
|
|
232
|
+
const statusMap: Record<string, Payment['status']> = {
|
|
233
|
+
PENDING: 'pending',
|
|
234
|
+
SUCCESSFUL: 'completed',
|
|
235
|
+
FAILED: 'failed',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return statusMap[result.status] || 'pending';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async checkMpesaStatus(_checkoutRequestId: string): Promise<Payment['status']> {
|
|
242
|
+
// M-Pesa query implementation
|
|
243
|
+
return 'pending';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async getAccessToken(): Promise<string> {
|
|
247
|
+
// Token fetching logic varies by provider
|
|
248
|
+
// This is a simplified version
|
|
249
|
+
if (this.config.provider === 'mtn') {
|
|
250
|
+
const auth = Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64');
|
|
251
|
+
|
|
252
|
+
const response = await fetch(`${this.baseUrl}/collection/token/`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Basic ${auth}`,
|
|
256
|
+
'Ocp-Apim-Subscription-Key': this.config.apiKey,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error('Failed to get MTN access token');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const data = (await response.json()) as { access_token: string };
|
|
265
|
+
return data.access_token;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return this.config.apiSecret;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function createMobileMoneyProvider(config: MobileMoneyConfig): MobileMoneyProvider {
|
|
273
|
+
return new MobileMoneyProvider(config);
|
|
274
|
+
}
|