wirejs-module-payments-stripe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +466 -0
- package/package.json +41 -0
package/README.md
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Endpoint, Resource, Setting } from 'wirejs-resources';
|
|
2
|
+
import Stripe from 'stripe';
|
|
3
|
+
export type CheckoutMode = 'subscription' | 'payment';
|
|
4
|
+
export type Customer = {
|
|
5
|
+
id: string;
|
|
6
|
+
};
|
|
7
|
+
export type SubscriptionInterval = "day" | "week" | "month" | "year";
|
|
8
|
+
export type Product = {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
type: 'one_time' | 'recurring';
|
|
12
|
+
interval?: SubscriptionInterval;
|
|
13
|
+
currency: 'usd';
|
|
14
|
+
/**
|
|
15
|
+
* In cents
|
|
16
|
+
*/
|
|
17
|
+
unitAmount: number;
|
|
18
|
+
metadata: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
export type Price = {
|
|
21
|
+
product: string | {
|
|
22
|
+
id: string;
|
|
23
|
+
} | undefined;
|
|
24
|
+
recurring: {
|
|
25
|
+
interval: SubscriptionInterval;
|
|
26
|
+
} | null;
|
|
27
|
+
currency: string;
|
|
28
|
+
unit_amount: number | null;
|
|
29
|
+
metadata: Record<string, string>;
|
|
30
|
+
};
|
|
31
|
+
export type OneTimeProduct = Product & {
|
|
32
|
+
type: 'one_time';
|
|
33
|
+
};
|
|
34
|
+
export type SubscriptionProduct = Product & {
|
|
35
|
+
type: 'recurring';
|
|
36
|
+
};
|
|
37
|
+
export type LineItem<T extends CheckoutMode> = {
|
|
38
|
+
product: Product & (T extends 'subscription' ? SubscriptionProduct : OneTimeProduct);
|
|
39
|
+
quantity: number;
|
|
40
|
+
};
|
|
41
|
+
export type OneTimePurchaseLineItem = LineItem<'payment'>;
|
|
42
|
+
export type SubscriptionLineItem = LineItem<'subscription'>;
|
|
43
|
+
export type Transaction = {
|
|
44
|
+
/**
|
|
45
|
+
* Stripe ID.
|
|
46
|
+
*/
|
|
47
|
+
id: string;
|
|
48
|
+
customerId: string;
|
|
49
|
+
type: 'payment' | 'refund';
|
|
50
|
+
/**
|
|
51
|
+
* cents, always positive
|
|
52
|
+
*/
|
|
53
|
+
amount: number;
|
|
54
|
+
currency: string;
|
|
55
|
+
/**
|
|
56
|
+
* only used for refunds
|
|
57
|
+
*/
|
|
58
|
+
relatedPaymentId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* optional for refunds
|
|
61
|
+
*/
|
|
62
|
+
reason?: string;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
items?: Array<{
|
|
65
|
+
id: string;
|
|
66
|
+
description: string | null;
|
|
67
|
+
quantity: number | null;
|
|
68
|
+
amount: number;
|
|
69
|
+
productId?: string;
|
|
70
|
+
}>;
|
|
71
|
+
};
|
|
72
|
+
export type SubscriptionLine = {
|
|
73
|
+
/**
|
|
74
|
+
* Stripe Line Item ID
|
|
75
|
+
*/
|
|
76
|
+
id: string;
|
|
77
|
+
/**
|
|
78
|
+
* Stripe Subscription ID
|
|
79
|
+
*/
|
|
80
|
+
subscriptionId: string;
|
|
81
|
+
customerId: string;
|
|
82
|
+
/**
|
|
83
|
+
* Internal product/plan ID.
|
|
84
|
+
*/
|
|
85
|
+
productId: string;
|
|
86
|
+
amount: number;
|
|
87
|
+
currency: 'usd';
|
|
88
|
+
interval: SubscriptionInterval;
|
|
89
|
+
quantity: number;
|
|
90
|
+
status: Stripe.Subscription['status'] | 'deleted';
|
|
91
|
+
currentPeriodStart: string;
|
|
92
|
+
currentPeriodEnd: string;
|
|
93
|
+
canceledAt?: string;
|
|
94
|
+
createdAt: string;
|
|
95
|
+
updatedAt: string;
|
|
96
|
+
metadata?: Record<string, string>;
|
|
97
|
+
};
|
|
98
|
+
export declare class PaymentService extends Resource {
|
|
99
|
+
devMode: Setting<{
|
|
100
|
+
readonly private: false;
|
|
101
|
+
readonly description: "If \"yes\", skips Stripe checkout and immediately records successful payments.";
|
|
102
|
+
readonly init: () => string;
|
|
103
|
+
readonly options: ["yes", "no"];
|
|
104
|
+
}>;
|
|
105
|
+
stripeKey: Setting<{
|
|
106
|
+
readonly private: true;
|
|
107
|
+
readonly description: "The API key issued by Stripe.";
|
|
108
|
+
}>;
|
|
109
|
+
stripeEndpointSecret: Setting<{
|
|
110
|
+
readonly private: true;
|
|
111
|
+
readonly description: "The verification secret issued by Stripe for this endpoint.";
|
|
112
|
+
}>;
|
|
113
|
+
private prices;
|
|
114
|
+
private transactions;
|
|
115
|
+
private subscriptionLines;
|
|
116
|
+
private customerInternalIdToExternalId;
|
|
117
|
+
private customerExternalIdToInternalId;
|
|
118
|
+
webhookEndpoint: Endpoint;
|
|
119
|
+
_stripe: undefined | Promise<Stripe | undefined>;
|
|
120
|
+
_endpointSecretData: undefined | Promise<string>;
|
|
121
|
+
constructor(scope: Resource | string, id: string);
|
|
122
|
+
/**
|
|
123
|
+
* Creates a raw Stripe client.
|
|
124
|
+
*
|
|
125
|
+
* @returns
|
|
126
|
+
*/
|
|
127
|
+
getClient(): Promise<Stripe | undefined>;
|
|
128
|
+
getEndpointSecret(): Promise<string>;
|
|
129
|
+
private getPrice;
|
|
130
|
+
private getProductFromPrice;
|
|
131
|
+
private getStripeCustomer;
|
|
132
|
+
private getInternalCustomer;
|
|
133
|
+
private recordInvoicePayment;
|
|
134
|
+
private recordCheckoutPayment;
|
|
135
|
+
private recordCustomerSubscriptionLine;
|
|
136
|
+
private recordCustomerSubscription;
|
|
137
|
+
private recordLocalModeCheckout;
|
|
138
|
+
createCheckoutUrl<T extends OneTimePurchaseLineItem | SubscriptionLineItem>({ customer, lineItems, successUrl, cancelUrl }: {
|
|
139
|
+
customer: Customer;
|
|
140
|
+
lineItems: T[];
|
|
141
|
+
successUrl: URL | string;
|
|
142
|
+
cancelUrl: URL | string;
|
|
143
|
+
}): Promise<string | null>;
|
|
144
|
+
cancelSubscriptionLine(id: string): Promise<void>;
|
|
145
|
+
listPayments(internalCustomerId: string): Promise<{
|
|
146
|
+
id: string;
|
|
147
|
+
customerId: string;
|
|
148
|
+
type: "payment" | "refund";
|
|
149
|
+
amount: number;
|
|
150
|
+
currency: string;
|
|
151
|
+
relatedPaymentId?: string | undefined;
|
|
152
|
+
reason?: string | undefined;
|
|
153
|
+
createdAt: string;
|
|
154
|
+
items?: {
|
|
155
|
+
id: string;
|
|
156
|
+
description: string | null;
|
|
157
|
+
quantity: number | null;
|
|
158
|
+
amount: number;
|
|
159
|
+
productId?: string | undefined;
|
|
160
|
+
}[] | undefined;
|
|
161
|
+
}[]>;
|
|
162
|
+
listSubscriptions(internalCustomerId: string): Promise<{
|
|
163
|
+
id: string;
|
|
164
|
+
subscriptionId: string;
|
|
165
|
+
customerId: string;
|
|
166
|
+
productId: string;
|
|
167
|
+
amount: number;
|
|
168
|
+
currency: "usd";
|
|
169
|
+
interval: SubscriptionInterval;
|
|
170
|
+
quantity: number;
|
|
171
|
+
status: Stripe.Subscription["status"] | "deleted";
|
|
172
|
+
currentPeriodStart: string;
|
|
173
|
+
currentPeriodEnd: string;
|
|
174
|
+
canceledAt?: string | undefined;
|
|
175
|
+
createdAt: string;
|
|
176
|
+
updatedAt: string;
|
|
177
|
+
metadata?: {
|
|
178
|
+
[x: string]: string;
|
|
179
|
+
} | undefined;
|
|
180
|
+
}[]>;
|
|
181
|
+
/**
|
|
182
|
+
* Produces a handler for incoming webhooks from Stripe.
|
|
183
|
+
*/
|
|
184
|
+
private handleStripeWebhook;
|
|
185
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { DistributedTable, Endpoint, KeyValueStore, PassThruParser, Resource, Setting } from 'wirejs-resources';
|
|
2
|
+
import Stripe from 'stripe';
|
|
3
|
+
function httpNotFound(context, message) {
|
|
4
|
+
context.responseCode = 404;
|
|
5
|
+
return message ? `Not Found - ${message}` : 'Not found';
|
|
6
|
+
}
|
|
7
|
+
function sum(values) {
|
|
8
|
+
let t = 0;
|
|
9
|
+
for (const v of values)
|
|
10
|
+
t += v;
|
|
11
|
+
return t;
|
|
12
|
+
}
|
|
13
|
+
export class PaymentService extends Resource {
|
|
14
|
+
devMode = new Setting(this, 'dev-mode', {
|
|
15
|
+
private: false,
|
|
16
|
+
description: 'If "yes", skips Stripe checkout and immediately records successful payments.',
|
|
17
|
+
init: () => 'no',
|
|
18
|
+
options: ['yes', 'no'],
|
|
19
|
+
});
|
|
20
|
+
stripeKey = new Setting(this, 'stripe-key', {
|
|
21
|
+
private: true,
|
|
22
|
+
description: 'The API key issued by Stripe.'
|
|
23
|
+
});
|
|
24
|
+
stripeEndpointSecret = new Setting(this, 'stripe-endpoint-secret', {
|
|
25
|
+
private: true,
|
|
26
|
+
description: "The verification secret issued by Stripe for this endpoint."
|
|
27
|
+
});
|
|
28
|
+
prices = new Map();
|
|
29
|
+
transactions = new DistributedTable(this, 'transactions', {
|
|
30
|
+
parse: (PassThruParser),
|
|
31
|
+
key: {
|
|
32
|
+
partition: { field: 'id', type: 'string' },
|
|
33
|
+
},
|
|
34
|
+
indexes: [
|
|
35
|
+
{
|
|
36
|
+
partition: { field: 'customerId', type: 'string' },
|
|
37
|
+
sort: { field: 'createdAt', type: 'string' }
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
subscriptionLines = new DistributedTable(this, 'subscriptions', {
|
|
42
|
+
parse: (PassThruParser),
|
|
43
|
+
key: {
|
|
44
|
+
partition: { field: 'id', type: 'string' }
|
|
45
|
+
},
|
|
46
|
+
indexes: [
|
|
47
|
+
{
|
|
48
|
+
partition: { field: 'customerId', type: 'string' },
|
|
49
|
+
sort: { field: 'status', type: 'string' }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
partition: { field: 'subscriptionId', type: 'string' },
|
|
53
|
+
sort: { field: 'status', type: 'string' }
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
customerInternalIdToExternalId = new KeyValueStore(this, 'customerInternalIdToExternalId');
|
|
58
|
+
customerExternalIdToInternalId = new KeyValueStore(this, 'customerExternalIdToInternalId');
|
|
59
|
+
webhookEndpoint = new Endpoint(this, 'webhook', {
|
|
60
|
+
handle: async (context) => this.handleStripeWebhook(context),
|
|
61
|
+
description: "The webhook Stripe needs to inform this software of payment and subscription changes."
|
|
62
|
+
});
|
|
63
|
+
_stripe = undefined;
|
|
64
|
+
_endpointSecretData = undefined;
|
|
65
|
+
constructor(scope, id) {
|
|
66
|
+
super(scope, id);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Creates a raw Stripe client.
|
|
70
|
+
*
|
|
71
|
+
* @returns
|
|
72
|
+
*/
|
|
73
|
+
getClient() {
|
|
74
|
+
this._stripe = this._stripe || new Promise(resolve => {
|
|
75
|
+
this.stripeKey.read().then(key => {
|
|
76
|
+
if (!!key) {
|
|
77
|
+
resolve(new Stripe(key.trim()));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
resolve(undefined);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return this._stripe;
|
|
85
|
+
}
|
|
86
|
+
getEndpointSecret() {
|
|
87
|
+
this._endpointSecretData = this._endpointSecretData || new Promise(resolve => {
|
|
88
|
+
this.stripeEndpointSecret.read().then(v => resolve(v || ''));
|
|
89
|
+
});
|
|
90
|
+
return this._endpointSecretData;
|
|
91
|
+
}
|
|
92
|
+
getPrice(product) {
|
|
93
|
+
const interval = (product.type === 'recurring' && product.interval)
|
|
94
|
+
? product.interval
|
|
95
|
+
: 'one_time';
|
|
96
|
+
const lookupKey = `${product.id}-${product.unitAmount}-${product.currency}-${interval}`;
|
|
97
|
+
if (!this.prices.has(product)) {
|
|
98
|
+
this.prices.set(product, new Promise(async (resolve) => {
|
|
99
|
+
const client = (await this.getClient());
|
|
100
|
+
const existing = await client.prices.list({
|
|
101
|
+
active: true,
|
|
102
|
+
lookup_keys: [lookupKey],
|
|
103
|
+
});
|
|
104
|
+
const existingPrice = existing.data[0];
|
|
105
|
+
if (existingPrice)
|
|
106
|
+
return resolve(existingPrice);
|
|
107
|
+
try {
|
|
108
|
+
await client.products.retrieve(product.id);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.code !== 'resource_missing')
|
|
112
|
+
throw error;
|
|
113
|
+
await client.products.create({
|
|
114
|
+
id: product.id,
|
|
115
|
+
name: product.name
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const newPrice = await client.prices.create({
|
|
119
|
+
currency: product.currency,
|
|
120
|
+
unit_amount: product.unitAmount,
|
|
121
|
+
lookup_key: lookupKey,
|
|
122
|
+
recurring: (product.type === 'recurring' && product.interval) ? {
|
|
123
|
+
interval: product.interval,
|
|
124
|
+
} : undefined,
|
|
125
|
+
metadata: product.metadata,
|
|
126
|
+
product: product.id,
|
|
127
|
+
});
|
|
128
|
+
resolve(newPrice);
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
return this.prices.get(product);
|
|
132
|
+
}
|
|
133
|
+
getProductFromPrice(price) {
|
|
134
|
+
let id;
|
|
135
|
+
let name;
|
|
136
|
+
if (typeof price.product === 'string') {
|
|
137
|
+
id = price.product;
|
|
138
|
+
name = id;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
id = price.product.id;
|
|
142
|
+
name = price.product.name;
|
|
143
|
+
}
|
|
144
|
+
if (!id)
|
|
145
|
+
throw new Error("Product ID missing.");
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
name,
|
|
149
|
+
type: price.recurring ? 'recurring' : 'one_time',
|
|
150
|
+
interval: price.recurring ? price.recurring.interval : undefined,
|
|
151
|
+
currency: price.currency,
|
|
152
|
+
unitAmount: price.unit_amount,
|
|
153
|
+
metadata: price.metadata
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async getStripeCustomer(internalId) {
|
|
157
|
+
const existing = await this.customerInternalIdToExternalId.get(internalId);
|
|
158
|
+
if (existing)
|
|
159
|
+
return existing;
|
|
160
|
+
const client = await this.getClient();
|
|
161
|
+
const newCustomer = await client?.customers.create();
|
|
162
|
+
if (!newCustomer)
|
|
163
|
+
throw new Error("Could not create Stripe customer");
|
|
164
|
+
await this.customerInternalIdToExternalId.set(internalId, newCustomer.id);
|
|
165
|
+
await this.customerExternalIdToInternalId.set(newCustomer.id, internalId);
|
|
166
|
+
return newCustomer.id;
|
|
167
|
+
}
|
|
168
|
+
async getInternalCustomer(customer) {
|
|
169
|
+
const externalId = typeof customer === 'string' ? customer : customer.id;
|
|
170
|
+
const internalId = await this.customerExternalIdToInternalId.get(externalId);
|
|
171
|
+
if (!internalId)
|
|
172
|
+
throw new Error("No internal customer ID found for Stripe ID.");
|
|
173
|
+
return internalId;
|
|
174
|
+
}
|
|
175
|
+
async recordInvoicePayment(invoice) {
|
|
176
|
+
// Should always be populated, because we always attach customers at checkout.
|
|
177
|
+
const internalCustomerId = await this.getInternalCustomer(invoice.customer);
|
|
178
|
+
await this.transactions.save({
|
|
179
|
+
amount: invoice.amount_paid,
|
|
180
|
+
createdAt: new Date().toISOString(),
|
|
181
|
+
currency: invoice.currency,
|
|
182
|
+
customerId: internalCustomerId,
|
|
183
|
+
id: invoice.id,
|
|
184
|
+
type: 'payment',
|
|
185
|
+
items: invoice.lines.data.map(line => ({
|
|
186
|
+
id: line.id,
|
|
187
|
+
description: line.description,
|
|
188
|
+
quantity: line.quantity,
|
|
189
|
+
amount: line.amount,
|
|
190
|
+
productId: line.pricing?.price_details?.product
|
|
191
|
+
}))
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async recordCheckoutPayment(session, items) {
|
|
195
|
+
const internalCustomerId = await this.getInternalCustomer(session.customer);
|
|
196
|
+
await this.transactions.save({
|
|
197
|
+
amount: session.amount_total,
|
|
198
|
+
createdAt: new Date().toISOString(),
|
|
199
|
+
currency: session.currency,
|
|
200
|
+
customerId: internalCustomerId,
|
|
201
|
+
id: session.id,
|
|
202
|
+
type: 'payment',
|
|
203
|
+
items: items.data.map(line => ({
|
|
204
|
+
id: line.id,
|
|
205
|
+
description: line.description,
|
|
206
|
+
quantity: line.quantity,
|
|
207
|
+
amount: line.amount_total,
|
|
208
|
+
productId: typeof line.price?.product === 'string'
|
|
209
|
+
? line.price.product
|
|
210
|
+
: line.price?.product.id
|
|
211
|
+
}))
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async recordCustomerSubscriptionLine(customerId, subscription, lineItem) {
|
|
215
|
+
const product = this.getProductFromPrice(lineItem.price);
|
|
216
|
+
const existing = await this.subscriptionLines.get({ id: lineItem.id });
|
|
217
|
+
await this.subscriptionLines.save({
|
|
218
|
+
id: lineItem.id,
|
|
219
|
+
subscriptionId: subscription.id,
|
|
220
|
+
customerId,
|
|
221
|
+
status: subscription.status,
|
|
222
|
+
productId: product.id,
|
|
223
|
+
amount: lineItem.price.unit_amount,
|
|
224
|
+
currency: lineItem.price.currency,
|
|
225
|
+
interval: lineItem.price.recurring?.interval,
|
|
226
|
+
quantity: lineItem.quantity,
|
|
227
|
+
currentPeriodStart: new Date(lineItem.current_period_start * 1000).toISOString(),
|
|
228
|
+
currentPeriodEnd: new Date(lineItem.current_period_end * 1000).toISOString(),
|
|
229
|
+
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
230
|
+
updatedAt: new Date().toISOString(),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
async recordCustomerSubscription(sub) {
|
|
234
|
+
const internalCustomerId = await this.getInternalCustomer(sub.customer);
|
|
235
|
+
const existingSubscriptionLines = await Array.fromAsync(this.subscriptionLines.query({
|
|
236
|
+
by: 'subscriptionId-status',
|
|
237
|
+
where: {
|
|
238
|
+
subscriptionId: { eq: sub.id },
|
|
239
|
+
status: { ne: 'deleted' }
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
const existingSubLineIds = new Map();
|
|
243
|
+
for (const li of existingSubscriptionLines) {
|
|
244
|
+
existingSubLineIds.set(li.id, li);
|
|
245
|
+
}
|
|
246
|
+
for (const item of sub.items.data) {
|
|
247
|
+
await this.recordCustomerSubscriptionLine(internalCustomerId, sub, item);
|
|
248
|
+
existingSubLineIds.delete(item.id);
|
|
249
|
+
}
|
|
250
|
+
// any remaining "existing" lines were absent from the sub event and
|
|
251
|
+
// must therefore be canceled locally
|
|
252
|
+
for (const li of existingSubLineIds.values()) {
|
|
253
|
+
await this.subscriptionLines.save({
|
|
254
|
+
...li,
|
|
255
|
+
status: 'deleted',
|
|
256
|
+
updatedAt: new Date().toISOString(),
|
|
257
|
+
canceledAt: new Date().toISOString(),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async recordLocalModeCheckout({ customer, lineItems, mode }) {
|
|
262
|
+
await this.transactions.save({
|
|
263
|
+
amount: sum(lineItems.map(li => li.product.unitAmount * li.quantity)),
|
|
264
|
+
createdAt: new Date().toISOString(),
|
|
265
|
+
currency: 'usd',
|
|
266
|
+
customerId: customer.id,
|
|
267
|
+
id: crypto.randomUUID(),
|
|
268
|
+
type: 'payment',
|
|
269
|
+
items: lineItems.map(li => ({
|
|
270
|
+
id: crypto.randomUUID(),
|
|
271
|
+
description: li.product.name,
|
|
272
|
+
quantity: li.quantity,
|
|
273
|
+
amount: li.product.unitAmount * li.quantity,
|
|
274
|
+
productId: li.product.id
|
|
275
|
+
}))
|
|
276
|
+
});
|
|
277
|
+
if (mode === 'subscription') {
|
|
278
|
+
const subscriptionId = crypto.randomUUID();
|
|
279
|
+
for (const li of lineItems) {
|
|
280
|
+
const now = new Date();
|
|
281
|
+
let currentPeriodEnd;
|
|
282
|
+
switch (li.product.interval) {
|
|
283
|
+
case 'day':
|
|
284
|
+
currentPeriodEnd = new Date(now);
|
|
285
|
+
currentPeriodEnd.setDate(now.getDate() + 1);
|
|
286
|
+
break;
|
|
287
|
+
case 'week':
|
|
288
|
+
currentPeriodEnd = new Date(now);
|
|
289
|
+
currentPeriodEnd.setDate(now.getDate() + 7);
|
|
290
|
+
break;
|
|
291
|
+
case 'month':
|
|
292
|
+
currentPeriodEnd = new Date(now);
|
|
293
|
+
currentPeriodEnd.setMonth(now.getMonth() + 1);
|
|
294
|
+
break;
|
|
295
|
+
case 'year':
|
|
296
|
+
currentPeriodEnd = new Date(now);
|
|
297
|
+
currentPeriodEnd.setFullYear(now.getFullYear() + 1);
|
|
298
|
+
break;
|
|
299
|
+
default:
|
|
300
|
+
currentPeriodEnd = new Date(now);
|
|
301
|
+
}
|
|
302
|
+
await this.subscriptionLines.save({
|
|
303
|
+
id: crypto.randomUUID(),
|
|
304
|
+
customerId: customer.id,
|
|
305
|
+
createdAt: now.toISOString(),
|
|
306
|
+
updatedAt: now.toISOString(),
|
|
307
|
+
productId: li.product.id,
|
|
308
|
+
amount: li.product.unitAmount,
|
|
309
|
+
currency: li.product.currency,
|
|
310
|
+
interval: li.product.interval,
|
|
311
|
+
quantity: li.quantity,
|
|
312
|
+
status: 'active',
|
|
313
|
+
subscriptionId: subscriptionId,
|
|
314
|
+
currentPeriodStart: now.toISOString(),
|
|
315
|
+
currentPeriodEnd: currentPeriodEnd.toISOString()
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async createCheckoutUrl({ customer, lineItems, successUrl, cancelUrl }) {
|
|
321
|
+
if (lineItems.length === 0) {
|
|
322
|
+
throw new Error("`lineItems` is empty.");
|
|
323
|
+
}
|
|
324
|
+
const billingType = lineItems[0].product.type;
|
|
325
|
+
if (!lineItems.every(li => li.product.type === billingType)) {
|
|
326
|
+
throw new Error("`lineItems` contains mixed `type` values.");
|
|
327
|
+
}
|
|
328
|
+
const mode = billingType === 'one_time' ? 'payment' : 'subscription';
|
|
329
|
+
if (await this.devMode.read() === 'yes') {
|
|
330
|
+
await this.recordLocalModeCheckout({ customer, lineItems, mode });
|
|
331
|
+
return new URL(successUrl).href;
|
|
332
|
+
}
|
|
333
|
+
const stripe = await this.getClient();
|
|
334
|
+
if (!stripe) {
|
|
335
|
+
throw new Error("Stripe client could not be created. Ensure Stripe settings are populated.");
|
|
336
|
+
}
|
|
337
|
+
const lines = [];
|
|
338
|
+
for (const item of lineItems) {
|
|
339
|
+
lines.push({
|
|
340
|
+
price: (await this.getPrice(item.product)).id,
|
|
341
|
+
quantity: item.quantity,
|
|
342
|
+
adjustable_quantity: {
|
|
343
|
+
enabled: false
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const stripeCustomerId = await this.getStripeCustomer(customer.id);
|
|
348
|
+
const session = await stripe.checkout.sessions.create({
|
|
349
|
+
mode,
|
|
350
|
+
customer: stripeCustomerId,
|
|
351
|
+
line_items: lines,
|
|
352
|
+
allow_promotion_codes: true,
|
|
353
|
+
success_url: new URL(successUrl).href,
|
|
354
|
+
cancel_url: new URL(cancelUrl).href
|
|
355
|
+
});
|
|
356
|
+
return session.url;
|
|
357
|
+
}
|
|
358
|
+
async cancelSubscriptionLine(id) {
|
|
359
|
+
const existingLine = await this.subscriptionLines.get({ id });
|
|
360
|
+
if (!existingLine) {
|
|
361
|
+
throw new Error(`Subscription Line doesn't exist: ${id}.`);
|
|
362
|
+
}
|
|
363
|
+
if (await this.devMode.read() === 'yes') {
|
|
364
|
+
await this.subscriptionLines.save({
|
|
365
|
+
...existingLine,
|
|
366
|
+
status: 'deleted',
|
|
367
|
+
updatedAt: new Date().toISOString(),
|
|
368
|
+
canceledAt: new Date().toISOString(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const stripe = await this.getClient();
|
|
372
|
+
if (!stripe) {
|
|
373
|
+
throw new Error("Stripe client could not be created. Ensure Stripe settings are populated.");
|
|
374
|
+
}
|
|
375
|
+
const allSubLines = await Array.fromAsync(this.subscriptionLines.query({
|
|
376
|
+
by: 'subscriptionId-status',
|
|
377
|
+
where: {
|
|
378
|
+
subscriptionId: { eq: existingLine.subscriptionId },
|
|
379
|
+
status: { ne: 'deleted ' }
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
if (allSubLines.length > 1) {
|
|
383
|
+
await stripe.subscriptionItems.del(id);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
await stripe.subscriptions.cancel(existingLine.subscriptionId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async listPayments(internalCustomerId) {
|
|
390
|
+
return Array.fromAsync(this.transactions.query({
|
|
391
|
+
by: 'customerId-createdAt',
|
|
392
|
+
where: {
|
|
393
|
+
customerId: { eq: internalCustomerId }
|
|
394
|
+
},
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
async listSubscriptions(internalCustomerId) {
|
|
398
|
+
return Array.fromAsync(this.subscriptionLines.query({
|
|
399
|
+
by: 'customerId-status',
|
|
400
|
+
where: {
|
|
401
|
+
customerId: { eq: internalCustomerId }
|
|
402
|
+
}
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Produces a handler for incoming webhooks from Stripe.
|
|
407
|
+
*/
|
|
408
|
+
async handleStripeWebhook(context) {
|
|
409
|
+
console.log('Stripe callback event');
|
|
410
|
+
const stripe = await this.getClient();
|
|
411
|
+
if (!stripe) {
|
|
412
|
+
throw new Error("Stripe client is not available.");
|
|
413
|
+
}
|
|
414
|
+
const endpointSecret = (await this.getEndpointSecret()).trim();
|
|
415
|
+
if (!endpointSecret) {
|
|
416
|
+
throw new Error("No Stripe endpoint secret configured.");
|
|
417
|
+
}
|
|
418
|
+
let stripeEvent;
|
|
419
|
+
const signature = context.requestHeaders['stripe-signature'];
|
|
420
|
+
try {
|
|
421
|
+
stripeEvent = stripe.webhooks.constructEvent(context.requestBody, signature, endpointSecret);
|
|
422
|
+
console.log("Stripe event validated");
|
|
423
|
+
console.log(stripeEvent);
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
console.log(`STRIPE FAILURE: Webhook signature verification failed.`, err.message);
|
|
427
|
+
return httpNotFound(context);
|
|
428
|
+
}
|
|
429
|
+
// Handle the event
|
|
430
|
+
switch (stripeEvent.type) {
|
|
431
|
+
case "invoice.payment_succeeded": {
|
|
432
|
+
const invoice = stripeEvent.data.object;
|
|
433
|
+
await this.recordInvoicePayment(invoice);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case "checkout.session.completed": {
|
|
437
|
+
const session = stripeEvent.data.object;
|
|
438
|
+
if (session.mode === 'payment') {
|
|
439
|
+
const items = await stripe.checkout.sessions.listLineItems(session.id);
|
|
440
|
+
await this.recordCheckoutPayment(session, items);
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'customer.subscription.trial_will_end':
|
|
445
|
+
// Then define and call a method to handle the subscription trial ending.
|
|
446
|
+
// handleSubscriptionTrialEnding(subscription);
|
|
447
|
+
// nothing to do here for now ... we don't offer trials.
|
|
448
|
+
break;
|
|
449
|
+
case 'customer.subscription.deleted':
|
|
450
|
+
case 'customer.subscription.paused':
|
|
451
|
+
case 'customer.subscription.updated':
|
|
452
|
+
case 'customer.subscription.created':
|
|
453
|
+
case 'customer.subscription.resumed': {
|
|
454
|
+
const sub = stripeEvent.data.object;
|
|
455
|
+
await this.recordCustomerSubscription(sub);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
default: {
|
|
459
|
+
// Unexpected event type
|
|
460
|
+
console.log(`Unhandled Stripe event type ${stripeEvent.type}.`);
|
|
461
|
+
return httpNotFound(context, "Unhandled Stripe event type ${stripeEvent.type}.");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return "ok";
|
|
465
|
+
}
|
|
466
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wirejs-module-payments-stripe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stripe payments module built for wirejs apps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/svidgen/create-wirejs-app.git",
|
|
20
|
+
"directory": "packages/wirejs-module-payments-stripe"
|
|
21
|
+
},
|
|
22
|
+
"author": "Jon Wire",
|
|
23
|
+
"license": "AGPL-3.0-only",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/svidgen/create-wirejs-app/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/svidgen/create-wirejs-app#readme",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"stripe": "^18.2.1",
|
|
30
|
+
"wirejs-dom": "^1.0.42",
|
|
31
|
+
"wirejs-resources": "^0.1.106"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.7.3"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"package.json",
|
|
38
|
+
"README.md",
|
|
39
|
+
"dist/*"
|
|
40
|
+
]
|
|
41
|
+
}
|