@misterhomer1992/payment-manager 1.0.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/.editorconfig +7 -0
- package/.prettierrc +5 -0
- package/README.md +181 -0
- package/USAGE.md +400 -0
- package/dist/collections/payments.d.ts +6 -0
- package/dist/collections/payments.js +60 -0
- package/dist/collections/plans.d.ts +6 -0
- package/dist/collections/plans.js +50 -0
- package/dist/collections/subscriptions.d.ts +6 -0
- package/dist/collections/subscriptions.js +47 -0
- package/dist/collections/tokenPacks.d.ts +6 -0
- package/dist/collections/tokenPacks.js +42 -0
- package/dist/collections/userTokenBalances.d.ts +4 -0
- package/dist/collections/userTokenBalances.js +41 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +14 -0
- package/dist/constants/defaultPlans.d.ts +2 -0
- package/dist/constants/defaultPlans.js +37 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +88 -0
- package/dist/services/paymentService.d.ts +8 -0
- package/dist/services/paymentService.js +173 -0
- package/dist/services/planService.d.ts +7 -0
- package/dist/services/planService.js +69 -0
- package/dist/services/subscriptionService.d.ts +13 -0
- package/dist/services/subscriptionService.js +183 -0
- package/dist/services/tokenPackService.d.ts +15 -0
- package/dist/services/tokenPackService.js +80 -0
- package/dist/services/webhookService.d.ts +3 -0
- package/dist/services/webhookService.js +210 -0
- package/dist/types/errors.d.ts +31 -0
- package/dist/types/errors.js +74 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.js +14 -0
- package/dist/types/orderReference.d.ts +18 -0
- package/dist/types/orderReference.js +2 -0
- package/dist/types/payment.d.ts +14 -0
- package/dist/types/payment.js +2 -0
- package/dist/types/plan.d.ts +10 -0
- package/dist/types/plan.js +2 -0
- package/dist/types/subscription.d.ts +12 -0
- package/dist/types/subscription.js +2 -0
- package/dist/types/tokenPack.d.ts +15 -0
- package/dist/types/tokenPack.js +2 -0
- package/dist/types/webhook.d.ts +32 -0
- package/dist/types/webhook.js +2 -0
- package/dist/utils/gracePeriod.d.ts +3 -0
- package/dist/utils/gracePeriod.js +19 -0
- package/dist/utils/orderReference.d.ts +3 -0
- package/dist/utils/orderReference.js +52 -0
- package/dist/utils/proration.d.ts +10 -0
- package/dist/utils/proration.js +32 -0
- package/dist/utils/webhookResponse.d.ts +2 -0
- package/dist/utils/webhookResponse.js +10 -0
- package/jest.config.ts +11 -0
- package/package.json +29 -0
- package/src/collections/payments.ts +61 -0
- package/src/collections/plans.ts +53 -0
- package/src/collections/subscriptions.ts +52 -0
- package/src/collections/tokenPacks.ts +42 -0
- package/src/collections/userTokenBalances.ts +46 -0
- package/src/config.ts +34 -0
- package/src/index.ts +129 -0
- package/src/services/deactivationCheckHandler.test.ts +248 -0
- package/src/services/paymentCheckHandler.test.ts +384 -0
- package/src/services/paymentService.ts +166 -0
- package/src/services/planService.ts +46 -0
- package/src/services/subscriptionService.ts +183 -0
- package/src/services/tokenPackService.ts +54 -0
- package/src/services/webhookService.ts +217 -0
- package/src/types/errors.ts +72 -0
- package/src/types/index.ts +18 -0
- package/src/types/orderReference.ts +19 -0
- package/src/types/payment.ts +14 -0
- package/src/types/plan.ts +10 -0
- package/src/types/subscription.ts +12 -0
- package/src/types/tokenPack.ts +16 -0
- package/src/types/webhook.ts +34 -0
- package/src/utils/gracePeriod.test.ts +46 -0
- package/src/utils/gracePeriod.ts +19 -0
- package/src/utils/orderReference.test.ts +149 -0
- package/src/utils/orderReference.ts +57 -0
- package/src/utils/proration.test.ts +133 -0
- package/src/utils/proration.ts +50 -0
- package/src/utils/webhookResponse.test.ts +57 -0
- package/src/utils/webhookResponse.ts +14 -0
- package/tasks/prd-payment-manager-library-product-requirements-document.md +447 -0
- package/tasks/prd.json +336 -0
- package/tsconfig.json +15 -0
package/.editorconfig
ADDED
package/.prettierrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# payment-manager
|
|
2
|
+
|
|
3
|
+
A TypeScript library for managing subscriptions, one-time token purchases, and payment webhooks with [WayForPay](https://wayforpay.com/) and [Cloud Firestore](https://firebase.google.com/docs/firestore).
|
|
4
|
+
|
|
5
|
+
Framework-agnostic — wire it into Express, Fastify, Cloud Functions, or anything else.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Subscription lifecycle: create, upgrade/downgrade (with proration), cancel, expire
|
|
10
|
+
- One-time token pack purchases
|
|
11
|
+
- WayForPay webhook handling (payment confirmation + recurrent billing)
|
|
12
|
+
- Cron-compatible expiration checks with grace periods
|
|
13
|
+
- Payment URL caching (5-minute deduplication window)
|
|
14
|
+
- Typed error classes for every failure scenario
|
|
15
|
+
- Optional structured logging
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install payment-manager firebase-admin
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`firebase-admin` (>=12.0.0) is a peer dependency.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import * as admin from 'firebase-admin';
|
|
29
|
+
import { init } from 'payment-manager';
|
|
30
|
+
|
|
31
|
+
admin.initializeApp();
|
|
32
|
+
|
|
33
|
+
const pm = init({
|
|
34
|
+
firestore: admin.firestore(),
|
|
35
|
+
wayforpay: {
|
|
36
|
+
merchantAccount: 'your_merchant_account',
|
|
37
|
+
merchantSecretKey: 'your_merchant_secret_key',
|
|
38
|
+
merchantDomainName: 'example.com',
|
|
39
|
+
},
|
|
40
|
+
platform: 'my-app',
|
|
41
|
+
webhookSecret: 'your-webhook-secret',
|
|
42
|
+
logger: console,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await pm.plans.seedDefaultPlans([
|
|
46
|
+
{ id: 'basic-monthly', name: 'Basic', amount: 100, regularMode: 'monthly', isActive: true },
|
|
47
|
+
{ id: 'pro-monthly', name: 'Pro', amount: 300, regularMode: 'monthly', isActive: true },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const { paymentUrl } = await pm.subscriptions.create({
|
|
51
|
+
userId: 'user-123',
|
|
52
|
+
planId: 'basic-monthly',
|
|
53
|
+
currency: 'UAH',
|
|
54
|
+
productName: 'My App Subscription',
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
interface PaymentManagerConfig {
|
|
62
|
+
firestore: Firestore;
|
|
63
|
+
wayforpay: {
|
|
64
|
+
merchantAccount: string;
|
|
65
|
+
merchantSecretKey: string;
|
|
66
|
+
merchantDomainName: string;
|
|
67
|
+
};
|
|
68
|
+
platform: string; // included in order references
|
|
69
|
+
webhookSecret?: string; // validates x-secret-key header on webhooks
|
|
70
|
+
logger?: { // any object with info/warn/error (console, pino, winston)
|
|
71
|
+
info: (message: string, ...args: unknown[]) => void;
|
|
72
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
73
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## API
|
|
79
|
+
|
|
80
|
+
`init()` returns a `PaymentManager` object with five namespaces.
|
|
81
|
+
|
|
82
|
+
### `pm.plans`
|
|
83
|
+
|
|
84
|
+
| Method | Signature | Description |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `seedDefaultPlans` | `(plans: PlanInput[]) => Promise<void>` | Upsert an array of plans by ID |
|
|
87
|
+
| `create` | `(plan: PlanInput) => Promise<void>` | Create a single plan |
|
|
88
|
+
| `getById` | `(planId: string) => Promise<SubscriptionPlanEntity \| null>` | Get a plan by ID |
|
|
89
|
+
| `getAll` | `() => Promise<SubscriptionPlanEntity[]>` | List all plans |
|
|
90
|
+
| `update` | `(planId: string, data: Partial<PlanInput>) => Promise<void>` | Update plan fields |
|
|
91
|
+
| `deactivate` | `(planId: string) => Promise<void>` | Mark a plan as inactive |
|
|
92
|
+
|
|
93
|
+
> `PlanInput` = `Omit<SubscriptionPlanEntity, 'createdAt' | 'updatedAt'>`
|
|
94
|
+
|
|
95
|
+
### `pm.subscriptions`
|
|
96
|
+
|
|
97
|
+
| Method | Signature | Description |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `create` | `(params: { userId, planId, currency, productName }) => Promise<{ paymentUrl }>` | Create a subscription and return a WayForPay payment URL |
|
|
100
|
+
| `getActiveByUserId` | `(userId: string) => Promise<SubscriptionEntity \| null>` | Get the active subscription for a user |
|
|
101
|
+
| `changePlan` | `(userId: string, newPlanId: string) => Promise<void>` | Change plan (upgrade = immediate proration, downgrade = next billing cycle) |
|
|
102
|
+
| `deactivate` | `(userId: string) => Promise<void>` | Cancel subscription via WayForPay |
|
|
103
|
+
| `expire` | `(userId: string) => Promise<void>` | Manually expire a subscription |
|
|
104
|
+
|
|
105
|
+
### `pm.tokenPacks`
|
|
106
|
+
|
|
107
|
+
| Method | Signature | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `create` | `(pack: TokenPackInput) => Promise<string>` | Create a token pack, returns the generated ID |
|
|
110
|
+
| `getAll` | `() => Promise<TokenPackEntity[]>` | List all token packs |
|
|
111
|
+
| `activate` | `(packId: string) => Promise<void>` | Activate a token pack |
|
|
112
|
+
| `deactivate` | `(packId: string) => Promise<void>` | Deactivate a token pack |
|
|
113
|
+
| `remove` | `(packId: string) => Promise<void>` | Delete a token pack |
|
|
114
|
+
| `buyExtraTokens` | `(params: { userId, packId, currency, productName }) => Promise<{ paymentUrl }>` | Purchase tokens, returns a one-time payment URL |
|
|
115
|
+
|
|
116
|
+
> `TokenPackInput` = `Omit<TokenPackEntity, 'id' | 'createdAt' | 'updatedAt'>`
|
|
117
|
+
|
|
118
|
+
### `pm.userTokens`
|
|
119
|
+
|
|
120
|
+
| Method | Signature | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `getBalance` | `(userId: string) => Promise<UserTokenBalance>` | Get user's token balance (returns zero-balance default if no record exists) |
|
|
123
|
+
|
|
124
|
+
### `pm.webhooks`
|
|
125
|
+
|
|
126
|
+
| Method | Signature | Description |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `handlePaymentCheck` | `(reqBody, headers) => Promise<WebhookResponse>` | Handle WayForPay `PAYMENT_CHECK` callback |
|
|
129
|
+
| `handleSubscriptionDeactivationCheck` | `(headers) => Promise<DeactivationResult>` | Expire overdue subscriptions and apply pending downgrades (call from a cron job) |
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
All errors extend `PaymentManagerError` with a `code` property:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { PaymentManagerError } from 'payment-manager';
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await pm.subscriptions.create({ userId, planId, currency, productName });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof PaymentManagerError) {
|
|
142
|
+
console.error(error.code, error.message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Error | Code |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `PlanNotFoundError` | `PLAN_NOT_FOUND` |
|
|
150
|
+
| `PlanInactiveError` | `PLAN_INACTIVE` |
|
|
151
|
+
| `SubscriptionNotFoundError` | `SUBSCRIPTION_NOT_FOUND` |
|
|
152
|
+
| `SubscriptionAlreadyActiveError` | `SUBSCRIPTION_ALREADY_ACTIVE` |
|
|
153
|
+
| `TokenPackNotFoundError` | `TOKEN_PACK_NOT_FOUND` |
|
|
154
|
+
| `TokenPackInactiveError` | `TOKEN_PACK_INACTIVE` |
|
|
155
|
+
| `InvalidWebhookSecretError` | `INVALID_WEBHOOK_SECRET` |
|
|
156
|
+
| `PaymentProviderError` | `PAYMENT_PROVIDER_ERROR` |
|
|
157
|
+
| `InvalidOrderReferenceError` | `INVALID_ORDER_REFERENCE` |
|
|
158
|
+
|
|
159
|
+
## Grace Periods
|
|
160
|
+
|
|
161
|
+
The deactivation check uses grace periods before expiring subscriptions:
|
|
162
|
+
|
|
163
|
+
| Billing Cycle | Grace Period |
|
|
164
|
+
|---|---|
|
|
165
|
+
| daily | 6 hours |
|
|
166
|
+
| monthly | 1 day |
|
|
167
|
+
| yearly | 1 day |
|
|
168
|
+
|
|
169
|
+
## Firestore Collections
|
|
170
|
+
|
|
171
|
+
| Collection | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `subscriptionPlans` | Plan definitions |
|
|
174
|
+
| `subscriptions` | User subscription records |
|
|
175
|
+
| `payments` | Payment transactions |
|
|
176
|
+
| `tokenPacks` | Token pack definitions |
|
|
177
|
+
| `userTokenBalances` | Per-user token balances |
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
ISC
|
package/USAGE.md
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# Payment Manager
|
|
2
|
+
|
|
3
|
+
A standalone TypeScript library for managing subscriptions, one-time token pack purchases, and payment webhooks using **WayForPay** as the payment provider and **Firestore** as the database.
|
|
4
|
+
|
|
5
|
+
The library is framework-agnostic — it exposes plain async functions that you wire into your own HTTP routes (Express, Fastify, Cloud Functions, etc.).
|
|
6
|
+
|
|
7
|
+
## Key Features
|
|
8
|
+
|
|
9
|
+
- Full subscription lifecycle: create, upgrade/downgrade with proration, cancel, expire
|
|
10
|
+
- One-time token pack purchases (extra credits beyond subscription limits)
|
|
11
|
+
- WayForPay webhook handling for payment confirmation and recurrent billing
|
|
12
|
+
- Cron-compatible subscription expiration check with configurable grace periods
|
|
13
|
+
- Payment URL caching (avoids duplicate WayForPay API calls within a 5-minute window)
|
|
14
|
+
- Typed custom error classes for every failure scenario
|
|
15
|
+
- Optional structured logging
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install payment-manager
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Peer Dependencies
|
|
24
|
+
|
|
25
|
+
The library requires `firebase-admin` (>=12.0.0) as a peer dependency. Install it in your project if you haven't already:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install firebase-admin
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import * as admin from 'firebase-admin';
|
|
35
|
+
import { init } from 'payment-manager';
|
|
36
|
+
|
|
37
|
+
admin.initializeApp();
|
|
38
|
+
const firestore = admin.firestore();
|
|
39
|
+
|
|
40
|
+
const pm = init({
|
|
41
|
+
firestore,
|
|
42
|
+
wayforpay: {
|
|
43
|
+
merchantAccount: 'your_merchant_account',
|
|
44
|
+
merchantSecretKey: 'your_merchant_secret_key',
|
|
45
|
+
merchantDomainName: 'yourdomain.com',
|
|
46
|
+
},
|
|
47
|
+
platform: 'my-app',
|
|
48
|
+
webhookSecret: 'your-webhook-secret', // optional
|
|
49
|
+
logger: console, // optional
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Seed your plans into Firestore
|
|
53
|
+
await pm.plans.seedDefaultPlans([
|
|
54
|
+
{ id: 'basic-monthly', name: 'Basic Monthly', amount: 100, regularMode: 'monthly', isActive: true },
|
|
55
|
+
{ id: 'pro-monthly', name: 'Pro Monthly', amount: 300, regularMode: 'monthly', isActive: true },
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// Create a subscription (returns a WayForPay payment URL)
|
|
59
|
+
const { paymentUrl } = await pm.subscriptions.create({
|
|
60
|
+
userId: 'user-123',
|
|
61
|
+
planId: 'basic-monthly',
|
|
62
|
+
currency: 'UAH',
|
|
63
|
+
productName: 'My App Subscription',
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Pass a `PaymentManagerConfig` object to `init()`:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
interface PaymentManagerConfig {
|
|
73
|
+
/** Firestore instance from firebase-admin */
|
|
74
|
+
firestore: Firestore;
|
|
75
|
+
|
|
76
|
+
/** WayForPay merchant credentials */
|
|
77
|
+
wayforpay: {
|
|
78
|
+
merchantAccount: string;
|
|
79
|
+
merchantSecretKey: string;
|
|
80
|
+
merchantDomainName: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Platform identifier included in order references */
|
|
84
|
+
platform: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Secret for validating incoming webhook requests (x-secret-key header).
|
|
88
|
+
* Falls back to process.env.NOTIFY_TELEGRAM_USER_SECRET if not provided.
|
|
89
|
+
*/
|
|
90
|
+
webhookSecret?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Optional logger. Any object with info/warn/error methods works
|
|
94
|
+
* (e.g. console, winston, pino).
|
|
95
|
+
*/
|
|
96
|
+
logger?: {
|
|
97
|
+
info: (message: string, ...args: unknown[]) => void;
|
|
98
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
99
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Calling any method before `init()` throws:
|
|
105
|
+
```
|
|
106
|
+
Error: PaymentManager is not initialized. Call init(config) first.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## API Reference
|
|
110
|
+
|
|
111
|
+
The `init()` function returns a `PaymentManager` object with five namespaces:
|
|
112
|
+
|
|
113
|
+
### `pm.plans` — Subscription Plan Management
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// Seed your plans into Firestore (upserts by ID)
|
|
117
|
+
await pm.plans.seedDefaultPlans([
|
|
118
|
+
{ id: 'basic-monthly', name: 'Basic Monthly', amount: 100, regularMode: 'monthly', isActive: true },
|
|
119
|
+
{ id: 'pro-monthly', name: 'Pro Monthly', amount: 300, regularMode: 'monthly', isActive: true },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// CRUD operations
|
|
123
|
+
await pm.plans.create({ id: 'enterprise', name: 'Enterprise', amount: 500, regularMode: 'monthly', isActive: true });
|
|
124
|
+
const plan = await pm.plans.getById('basic-monthly');
|
|
125
|
+
const allPlans = await pm.plans.getAll();
|
|
126
|
+
await pm.plans.update('enterprise', { amount: 450 });
|
|
127
|
+
await pm.plans.deactivate('enterprise');
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `pm.subscriptions` — Subscription Lifecycle
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
// Create a new subscription (cancels any existing active subscription first)
|
|
134
|
+
const { paymentUrl } = await pm.subscriptions.create({
|
|
135
|
+
userId: 'user-123',
|
|
136
|
+
planId: 'pro-monthly',
|
|
137
|
+
currency: 'UAH',
|
|
138
|
+
productName: 'My App Pro',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Get current active subscription
|
|
142
|
+
const sub = await pm.subscriptions.getActiveByUserId('user-123');
|
|
143
|
+
|
|
144
|
+
// Change plan (upgrade = immediate proration, downgrade = applied at next billing cycle)
|
|
145
|
+
await pm.subscriptions.changePlan('user-123', 'basic-monthly');
|
|
146
|
+
|
|
147
|
+
// Cancel subscription (calls WayForPay cancelRegularPurchase)
|
|
148
|
+
await pm.subscriptions.deactivate('user-123');
|
|
149
|
+
|
|
150
|
+
// Manually expire a subscription
|
|
151
|
+
await pm.subscriptions.expire('user-123');
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Plan change behavior:**
|
|
155
|
+
- **Upgrade** (higher price): The remaining value of the current plan is prorated and converted into days on the new plan. The change is applied immediately.
|
|
156
|
+
- **Downgrade** (lower price): The new plan is stored as `pendingPlanId` and applied automatically at the next billing cycle.
|
|
157
|
+
|
|
158
|
+
### `pm.tokenPacks` — One-Time Token Purchases
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// Create and manage token packs
|
|
162
|
+
const packId = await pm.tokenPacks.create({ name: '100 Tokens', tokenAmount: 100, price: 50, isActive: true });
|
|
163
|
+
const allPacks = await pm.tokenPacks.getAll();
|
|
164
|
+
await pm.tokenPacks.deactivate(packId);
|
|
165
|
+
await pm.tokenPacks.activate(packId);
|
|
166
|
+
await pm.tokenPacks.remove(packId);
|
|
167
|
+
|
|
168
|
+
// Purchase tokens (returns a one-time payment URL)
|
|
169
|
+
const { paymentUrl } = await pm.tokenPacks.buyExtraTokens({
|
|
170
|
+
userId: 'user-123',
|
|
171
|
+
packId: 'pack-100',
|
|
172
|
+
currency: 'UAH',
|
|
173
|
+
productName: '100 Extra Tokens',
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `pm.userTokens` — Token Balance
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// Get user's token balance (returns zero-balance default if no record exists)
|
|
181
|
+
const balance = await pm.userTokens.getBalance('user-123');
|
|
182
|
+
// balance: { userId, balance, totalPurchased, updatedAt }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### `pm.webhooks` — Webhook Handlers
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// Handle WayForPay PAYMENT_CHECK webhook
|
|
189
|
+
const response = await pm.webhooks.handlePaymentCheck(req.body, req.headers);
|
|
190
|
+
// response: { orderReference, status: 'accept' | 'reject', time, signature }
|
|
191
|
+
|
|
192
|
+
// Handle subscription expiration check (call from a cron job)
|
|
193
|
+
const result = await pm.webhooks.handleSubscriptionDeactivationCheck(req.headers);
|
|
194
|
+
// result: { expired: string[], downgraded: string[] }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Wiring into Your Server
|
|
198
|
+
|
|
199
|
+
### Express Example
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import express from 'express';
|
|
203
|
+
import * as admin from 'firebase-admin';
|
|
204
|
+
import { init } from 'payment-manager';
|
|
205
|
+
|
|
206
|
+
admin.initializeApp();
|
|
207
|
+
const app = express();
|
|
208
|
+
app.use(express.json());
|
|
209
|
+
|
|
210
|
+
const pm = init({
|
|
211
|
+
firestore: admin.firestore(),
|
|
212
|
+
wayforpay: {
|
|
213
|
+
merchantAccount: process.env.WFP_MERCHANT_ACCOUNT!,
|
|
214
|
+
merchantSecretKey: process.env.WFP_MERCHANT_SECRET!,
|
|
215
|
+
merchantDomainName: process.env.WFP_MERCHANT_DOMAIN!,
|
|
216
|
+
},
|
|
217
|
+
platform: 'my-app',
|
|
218
|
+
webhookSecret: process.env.WEBHOOK_SECRET,
|
|
219
|
+
logger: console,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// --- Plans ---
|
|
223
|
+
app.post('/api/plans/seed', async (req, res) => {
|
|
224
|
+
await pm.plans.seedDefaultPlans([
|
|
225
|
+
{ id: 'basic-monthly', name: 'Basic Monthly', amount: 100, regularMode: 'monthly', isActive: true },
|
|
226
|
+
{ id: 'pro-monthly', name: 'Pro Monthly', amount: 300, regularMode: 'monthly', isActive: true },
|
|
227
|
+
]);
|
|
228
|
+
res.json({ ok: true });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// --- Subscriptions ---
|
|
232
|
+
app.post('/api/subscriptions', async (req, res) => {
|
|
233
|
+
const { userId, planId, currency, productName } = req.body;
|
|
234
|
+
const result = await pm.subscriptions.create({ userId, planId, currency, productName });
|
|
235
|
+
res.json(result);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
app.post('/api/subscriptions/change-plan', async (req, res) => {
|
|
239
|
+
const { userId, newPlanId } = req.body;
|
|
240
|
+
await pm.subscriptions.changePlan(userId, newPlanId);
|
|
241
|
+
res.json({ ok: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
app.post('/api/subscriptions/cancel', async (req, res) => {
|
|
245
|
+
await pm.subscriptions.deactivate(req.body.userId);
|
|
246
|
+
res.json({ ok: true });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- Token Packs ---
|
|
250
|
+
app.post('/api/tokens/buy', async (req, res) => {
|
|
251
|
+
const { userId, packId, currency, productName } = req.body;
|
|
252
|
+
const result = await pm.tokenPacks.buyExtraTokens({ userId, packId, currency, productName });
|
|
253
|
+
res.json(result);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
app.get('/api/tokens/balance/:userId', async (req, res) => {
|
|
257
|
+
const balance = await pm.userTokens.getBalance(req.params.userId);
|
|
258
|
+
res.json(balance);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// --- Webhooks ---
|
|
262
|
+
app.post('/api/webhooks/payment', async (req, res) => {
|
|
263
|
+
const response = await pm.webhooks.handlePaymentCheck(req.body, req.headers as Record<string, string>);
|
|
264
|
+
res.json(response);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// --- Cron (subscription expiration check) ---
|
|
268
|
+
app.post('/api/cron/deactivation-check', async (req, res) => {
|
|
269
|
+
const result = await pm.webhooks.handleSubscriptionDeactivationCheck(req.headers as Record<string, string>);
|
|
270
|
+
res.json(result);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
app.listen(3000);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Firebase Cloud Functions Example
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import * as admin from 'firebase-admin';
|
|
280
|
+
import { onRequest } from 'firebase-functions/v2/https';
|
|
281
|
+
import { onSchedule } from 'firebase-functions/v2/scheduler';
|
|
282
|
+
import { init } from 'payment-manager';
|
|
283
|
+
|
|
284
|
+
admin.initializeApp();
|
|
285
|
+
|
|
286
|
+
const pm = init({
|
|
287
|
+
firestore: admin.firestore(),
|
|
288
|
+
wayforpay: {
|
|
289
|
+
merchantAccount: process.env.WFP_MERCHANT_ACCOUNT!,
|
|
290
|
+
merchantSecretKey: process.env.WFP_MERCHANT_SECRET!,
|
|
291
|
+
merchantDomainName: process.env.WFP_MERCHANT_DOMAIN!,
|
|
292
|
+
},
|
|
293
|
+
platform: 'my-app',
|
|
294
|
+
webhookSecret: process.env.WEBHOOK_SECRET,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
export const createSubscription = onRequest(async (req, res) => {
|
|
298
|
+
const { userId, planId, currency, productName } = req.body;
|
|
299
|
+
const result = await pm.subscriptions.create({ userId, planId, currency, productName });
|
|
300
|
+
res.json(result);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
export const wayforpayWebhook = onRequest(async (req, res) => {
|
|
304
|
+
const response = await pm.webhooks.handlePaymentCheck(req.body, req.headers as Record<string, string>);
|
|
305
|
+
res.json(response);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export const deactivationCheck = onSchedule('every 1 hours', async () => {
|
|
309
|
+
const headers = { 'x-secret-key': process.env.WEBHOOK_SECRET };
|
|
310
|
+
await pm.webhooks.handleSubscriptionDeactivationCheck(headers as Record<string, string>);
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Firestore Collections
|
|
315
|
+
|
|
316
|
+
The library creates and manages the following Firestore collections:
|
|
317
|
+
|
|
318
|
+
| Collection | Description |
|
|
319
|
+
|---|---|
|
|
320
|
+
| `subscriptionPlans` | Subscription plan definitions |
|
|
321
|
+
| `subscriptions` | User subscription records |
|
|
322
|
+
| `payments` | Payment transaction records |
|
|
323
|
+
| `tokenPacks` | Token pack definitions |
|
|
324
|
+
| `userTokenBalances` | Per-user token balance |
|
|
325
|
+
|
|
326
|
+
## Seeding Plans
|
|
327
|
+
|
|
328
|
+
`pm.plans.seedDefaultPlans(plans)` accepts an array of plan objects and upserts them by ID. Define whatever plans your application needs:
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
await pm.plans.seedDefaultPlans([
|
|
332
|
+
{ id: 'basic-monthly', name: 'Basic', amount: 100, regularMode: 'monthly', isActive: true },
|
|
333
|
+
{ id: 'pro-monthly', name: 'Pro', amount: 300, regularMode: 'monthly', isActive: true },
|
|
334
|
+
{ id: 'enterprise-yearly', name: 'Enterprise', amount: 3000, regularMode: 'yearly', description: 'Annual enterprise plan', isActive: true },
|
|
335
|
+
]);
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Error Handling
|
|
339
|
+
|
|
340
|
+
All errors extend `PaymentManagerError` with a `code` property for programmatic handling:
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
import { PlanNotFoundError, PaymentManagerError } from 'payment-manager';
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
await pm.subscriptions.create({ userId, planId, currency, productName });
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof PaymentManagerError) {
|
|
349
|
+
console.error(error.code, error.message);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
| Error Class | Code | When |
|
|
355
|
+
|---|---|---|
|
|
356
|
+
| `PlanNotFoundError` | `PLAN_NOT_FOUND` | Plan ID does not exist |
|
|
357
|
+
| `PlanInactiveError` | `PLAN_INACTIVE` | Plan exists but is deactivated |
|
|
358
|
+
| `SubscriptionNotFoundError` | `SUBSCRIPTION_NOT_FOUND` | No active subscription for user |
|
|
359
|
+
| `SubscriptionAlreadyActiveError` | `SUBSCRIPTION_ALREADY_ACTIVE` | User already has an active subscription |
|
|
360
|
+
| `TokenPackNotFoundError` | `TOKEN_PACK_NOT_FOUND` | Token pack ID does not exist |
|
|
361
|
+
| `TokenPackInactiveError` | `TOKEN_PACK_INACTIVE` | Token pack exists but is deactivated |
|
|
362
|
+
| `InvalidWebhookSecretError` | `INVALID_WEBHOOK_SECRET` | Webhook `x-secret-key` header mismatch |
|
|
363
|
+
| `PaymentProviderError` | `PAYMENT_PROVIDER_ERROR` | WayForPay API call failed |
|
|
364
|
+
| `InvalidOrderReferenceError` | `INVALID_ORDER_REFERENCE` | Malformed order reference string |
|
|
365
|
+
|
|
366
|
+
## Webhook Secret Validation
|
|
367
|
+
|
|
368
|
+
Both webhook handlers validate the `x-secret-key` request header against:
|
|
369
|
+
1. The `webhookSecret` from config (if provided)
|
|
370
|
+
2. `process.env.NOTIFY_TELEGRAM_USER_SECRET` (fallback)
|
|
371
|
+
|
|
372
|
+
If neither is set, secret validation is skipped.
|
|
373
|
+
|
|
374
|
+
## Grace Periods
|
|
375
|
+
|
|
376
|
+
The deactivation check handler uses grace periods before expiring subscriptions:
|
|
377
|
+
|
|
378
|
+
| Billing Cycle | Grace Period |
|
|
379
|
+
|---|---|
|
|
380
|
+
| daily | 6 hours |
|
|
381
|
+
| monthly | 1 day |
|
|
382
|
+
| yearly | 1 day |
|
|
383
|
+
|
|
384
|
+
## TypeScript
|
|
385
|
+
|
|
386
|
+
All types are exported from the package root:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
import type {
|
|
390
|
+
PaymentManagerConfig,
|
|
391
|
+
PaymentManager,
|
|
392
|
+
SubscriptionPlanEntity,
|
|
393
|
+
SubscriptionEntity,
|
|
394
|
+
PaymentEntity,
|
|
395
|
+
TokenPackEntity,
|
|
396
|
+
UserTokenBalance,
|
|
397
|
+
WebhookResponse,
|
|
398
|
+
DeactivationResult,
|
|
399
|
+
} from 'payment-manager';
|
|
400
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PaymentEntity } from '../types/index.js';
|
|
2
|
+
export declare function create(payment: Omit<PaymentEntity, 'id' | 'createdAt' | 'updatedAt'>): Promise<string>;
|
|
3
|
+
export declare function update(paymentId: string, data: Partial<Omit<PaymentEntity, 'id'>>): Promise<void>;
|
|
4
|
+
export declare function findPendingByUserAndPlan(userId: string, planId: string): Promise<PaymentEntity | null>;
|
|
5
|
+
export declare function findPendingByUserAndTokenPack(userId: string, packId: string): Promise<PaymentEntity | null>;
|
|
6
|
+
export declare function findByOrderReference(orderReference: string): Promise<PaymentEntity | null>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.create = create;
|
|
4
|
+
exports.update = update;
|
|
5
|
+
exports.findPendingByUserAndPlan = findPendingByUserAndPlan;
|
|
6
|
+
exports.findPendingByUserAndTokenPack = findPendingByUserAndTokenPack;
|
|
7
|
+
exports.findByOrderReference = findByOrderReference;
|
|
8
|
+
const config_js_1 = require("../config.js");
|
|
9
|
+
const COLLECTION = 'payments';
|
|
10
|
+
function collection() {
|
|
11
|
+
return (0, config_js_1.getConfig)().firestore.collection(COLLECTION);
|
|
12
|
+
}
|
|
13
|
+
async function create(payment) {
|
|
14
|
+
const now = new Date();
|
|
15
|
+
const docRef = await collection().add({
|
|
16
|
+
...payment,
|
|
17
|
+
createdAt: now,
|
|
18
|
+
updatedAt: now,
|
|
19
|
+
});
|
|
20
|
+
return docRef.id;
|
|
21
|
+
}
|
|
22
|
+
async function update(paymentId, data) {
|
|
23
|
+
await collection()
|
|
24
|
+
.doc(paymentId)
|
|
25
|
+
.update({
|
|
26
|
+
...data,
|
|
27
|
+
updatedAt: new Date(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function findPendingByUserAndPlan(userId, planId) {
|
|
31
|
+
const snapshot = await collection()
|
|
32
|
+
.where('userId', '==', userId)
|
|
33
|
+
.where('planId', '==', planId)
|
|
34
|
+
.where('status', '==', 'pending')
|
|
35
|
+
.limit(1)
|
|
36
|
+
.get();
|
|
37
|
+
if (snapshot.empty)
|
|
38
|
+
return null;
|
|
39
|
+
const doc = snapshot.docs[0];
|
|
40
|
+
return { id: doc.id, ...doc.data() };
|
|
41
|
+
}
|
|
42
|
+
async function findPendingByUserAndTokenPack(userId, packId) {
|
|
43
|
+
const snapshot = await collection()
|
|
44
|
+
.where('userId', '==', userId)
|
|
45
|
+
.where('tokenPackId', '==', packId)
|
|
46
|
+
.where('status', '==', 'pending')
|
|
47
|
+
.limit(1)
|
|
48
|
+
.get();
|
|
49
|
+
if (snapshot.empty)
|
|
50
|
+
return null;
|
|
51
|
+
const doc = snapshot.docs[0];
|
|
52
|
+
return { id: doc.id, ...doc.data() };
|
|
53
|
+
}
|
|
54
|
+
async function findByOrderReference(orderReference) {
|
|
55
|
+
const snapshot = await collection().where('orderReference', '==', orderReference).limit(1).get();
|
|
56
|
+
if (snapshot.empty)
|
|
57
|
+
return null;
|
|
58
|
+
const doc = snapshot.docs[0];
|
|
59
|
+
return { id: doc.id, ...doc.data() };
|
|
60
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SubscriptionPlanEntity } from '../types/index.js';
|
|
2
|
+
export declare function getById(planId: string): Promise<SubscriptionPlanEntity | null>;
|
|
3
|
+
export declare function getAll(): Promise<SubscriptionPlanEntity[]>;
|
|
4
|
+
export declare function create(plan: Omit<SubscriptionPlanEntity, 'createdAt' | 'updatedAt'>): Promise<void>;
|
|
5
|
+
export declare function update(planId: string, data: Partial<Omit<SubscriptionPlanEntity, 'id'>>): Promise<void>;
|
|
6
|
+
export declare function upsert(plan: Omit<SubscriptionPlanEntity, 'createdAt' | 'updatedAt'>): Promise<void>;
|