@sgftech/medusa-payment-stripe-subscription 0.0.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/.babelrc.js +12 -0
- package/.eslintrc.js +112 -0
- package/.github/dependabot.yml +21 -0
- package/.github/scripts/wait-for-server-live.sh +29 -0
- package/.github/workflows/test-cli.yml +149 -0
- package/.github/workflows/update-preview-deps-ci.yml +70 -0
- package/.github/workflows/update-preview-deps.yml +70 -0
- package/.vscode/settings.json +2 -0
- package/.yarnrc.yml +1 -0
- package/README.md +79 -0
- package/data/seed-onboarding.json +141 -0
- package/data/seed.json +1006 -0
- package/index.js +50 -0
- package/medusa-config.js +88 -0
- package/package.json +109 -0
- package/src/__fixtures__/data.ts +30 -0
- package/src/__mocks__/stripe.ts +104 -0
- package/src/api/README.md +179 -0
- package/src/core/__fixtures__/data.ts +225 -0
- package/src/core/__fixtures__/stripe-test.ts +12 -0
- package/src/core/__tests__/stripe-base.spec.ts +640 -0
- package/src/core/stripe-subscription.ts +523 -0
- package/src/index.ts +0 -0
- package/src/jobs/README.md +32 -0
- package/src/loaders/README.md +19 -0
- package/src/migrations/README.md +29 -0
- package/src/models/README.md +46 -0
- package/src/services/README.md +49 -0
- package/src/services/stripe-subscription-provider.ts +83 -0
- package/src/subscribers/README.md +44 -0
- package/src/subscribers/create-product-stripe.ts +23 -0
- package/src/subscribers/delete-product-stripe.ts +24 -0
- package/src/subscribers/stripe-event.ts +24 -0
- package/src/subscribers/update-product-stripe.ts +23 -0
- package/src/types/index.ts +9 -0
- package/src/utils/handle-customer-subscription.ts +36 -0
- package/src/utils/handle-invoice-subscription.ts +54 -0
- package/src/utils/handle-subscription.ts +64 -0
- package/src/utils/index.ts +143 -0
- package/tsconfig.admin.json +8 -0
- package/tsconfig.json +30 -0
- package/tsconfig.server.json +8 -0
- package/tsconfig.spec.json +5 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AbstractPaymentProcessor,
|
|
3
|
+
CartService,
|
|
4
|
+
isPaymentProcessorError,
|
|
5
|
+
Item,
|
|
6
|
+
LineItem,
|
|
7
|
+
LineItemTaxLine,
|
|
8
|
+
PaymentProcessorContext,
|
|
9
|
+
PaymentProcessorError,
|
|
10
|
+
PaymentProcessorSessionResponse,
|
|
11
|
+
PaymentSessionStatus
|
|
12
|
+
} from "@medusajs/medusa";
|
|
13
|
+
import { MedusaError } from "@medusajs/utils";
|
|
14
|
+
import { EOL } from "os";
|
|
15
|
+
import Stripe from "stripe";
|
|
16
|
+
import {
|
|
17
|
+
ErrorCodes,
|
|
18
|
+
ErrorIntentStatus,
|
|
19
|
+
PaymentIntentOptions,
|
|
20
|
+
StripeOptions
|
|
21
|
+
} from "medusa-payment-stripe";
|
|
22
|
+
import { StripeSubscriptionOptions } from "../types";
|
|
23
|
+
import StripeBase from "medusa-payment-stripe/dist/core/stripe-base";
|
|
24
|
+
|
|
25
|
+
abstract class StripeSubscriptionService extends StripeBase {
|
|
26
|
+
static identifier = "stripe-subscription";
|
|
27
|
+
|
|
28
|
+
protected readonly options_: StripeSubscriptionOptions;
|
|
29
|
+
protected stripe_: Stripe;
|
|
30
|
+
cartService: CartService;
|
|
31
|
+
|
|
32
|
+
protected constructor(container: { cartService: CartService }, options) {
|
|
33
|
+
super(container, options);
|
|
34
|
+
|
|
35
|
+
this.options_ = options;
|
|
36
|
+
this.cartService = container.cartService;
|
|
37
|
+
|
|
38
|
+
this.init();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected init(): void {
|
|
42
|
+
this.stripe_ =
|
|
43
|
+
this.stripe_ ||
|
|
44
|
+
new Stripe(this.options_.api_key, {
|
|
45
|
+
apiVersion: "2022-11-15"
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
abstract get paymentIntentOptions(): PaymentIntentOptions;
|
|
50
|
+
|
|
51
|
+
get options(): StripeSubscriptionOptions {
|
|
52
|
+
return this.options_;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getStripe(): Stripe {
|
|
56
|
+
return this.stripe_;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getPaymentIntentOptions(): PaymentIntentOptions {
|
|
60
|
+
const options: PaymentIntentOptions = {};
|
|
61
|
+
|
|
62
|
+
if (this?.paymentIntentOptions?.capture_method) {
|
|
63
|
+
options.capture_method = this.paymentIntentOptions.capture_method;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this?.paymentIntentOptions?.setup_future_usage) {
|
|
67
|
+
options.setup_future_usage =
|
|
68
|
+
this.paymentIntentOptions.setup_future_usage;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (this?.paymentIntentOptions?.payment_method_types) {
|
|
72
|
+
options.payment_method_types =
|
|
73
|
+
this.paymentIntentOptions.payment_method_types;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getPaymentStatus(
|
|
80
|
+
paymentSessionData: Record<string, unknown>
|
|
81
|
+
): Promise<PaymentSessionStatus> {
|
|
82
|
+
const id = paymentSessionData.id as string;
|
|
83
|
+
try {
|
|
84
|
+
const subscription = await this.stripe_.subscriptions.retrieve(id);
|
|
85
|
+
if (
|
|
86
|
+
subscription.status === "active" ||
|
|
87
|
+
subscription.status === "trialing"
|
|
88
|
+
) {
|
|
89
|
+
return PaymentSessionStatus.AUTHORIZED;
|
|
90
|
+
} else if (subscription.status === "canceled") {
|
|
91
|
+
return PaymentSessionStatus.CANCELED;
|
|
92
|
+
} else if (subscription.status === "incomplete") {
|
|
93
|
+
return PaymentSessionStatus.REQUIRES_MORE;
|
|
94
|
+
} else if (subscription.status === "incomplete_expired") {
|
|
95
|
+
return PaymentSessionStatus.REQUIRES_MORE;
|
|
96
|
+
} else {
|
|
97
|
+
return PaymentSessionStatus.PENDING;
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return super.getPaymentStatus(paymentSessionData);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async createStripeTaxRate(
|
|
105
|
+
taxLine: LineItemTaxLine,
|
|
106
|
+
country_code: string
|
|
107
|
+
): Promise<Stripe.TaxRate> {
|
|
108
|
+
const rate = await this.stripe_.taxRates.create({
|
|
109
|
+
display_name: taxLine.name,
|
|
110
|
+
inclusive: false,
|
|
111
|
+
percentage: taxLine.rate,
|
|
112
|
+
country: country_code
|
|
113
|
+
});
|
|
114
|
+
return rate;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getOrCreateStripeTaxRates(
|
|
118
|
+
i: LineItem,
|
|
119
|
+
country_code: string
|
|
120
|
+
): Promise<string[]> {
|
|
121
|
+
const promiseStripeTaxLines = i.tax_lines.map(async (taxLine) => {
|
|
122
|
+
let result: Stripe.TaxRate;
|
|
123
|
+
if (taxLine.metadata.stripe_tax_rate_id) {
|
|
124
|
+
try {
|
|
125
|
+
result = await this.stripe_.taxRates.retrieve(
|
|
126
|
+
taxLine.metadata.stripe_tax_rate_id as string
|
|
127
|
+
);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// don't do anything
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
result ??
|
|
135
|
+
(await this.createStripeTaxRate(taxLine, country_code))
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const stripeTaxLines = await Promise.all(promiseStripeTaxLines);
|
|
140
|
+
const stripeTaxLineIds = stripeTaxLines.map((i) => i.id);
|
|
141
|
+
return stripeTaxLineIds;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getStripeSubscriptionItemsFromCart(
|
|
145
|
+
cartId: string
|
|
146
|
+
): Promise<PaymentProcessorError | Stripe.SubscriptionCreateParams.Item[]> {
|
|
147
|
+
const cart = await this.cartService.retrieve(cartId, {
|
|
148
|
+
relations: [
|
|
149
|
+
"items",
|
|
150
|
+
"items.variant",
|
|
151
|
+
"items.variant.prices",
|
|
152
|
+
"items.variant.product",
|
|
153
|
+
"items.variant.metadata",
|
|
154
|
+
"items.tax_lines"
|
|
155
|
+
]
|
|
156
|
+
});
|
|
157
|
+
const { region } = await this.cartService.retrieve(cartId, {
|
|
158
|
+
relations: ["region"]
|
|
159
|
+
});
|
|
160
|
+
cart.region = region;
|
|
161
|
+
const subscribableItems = cart.items.filter(
|
|
162
|
+
(i) =>
|
|
163
|
+
(i.variant.metadata.subscription as string).toLowerCase() ==
|
|
164
|
+
"true"
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (subscribableItems.length == 0) {
|
|
168
|
+
return this.buildError(
|
|
169
|
+
"No subscribable items found in cart",
|
|
170
|
+
{} as PaymentProcessorError
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (subscribableItems.length > 20) {
|
|
175
|
+
return this.buildError(
|
|
176
|
+
"Too subscribable items found in cart",
|
|
177
|
+
{} as PaymentProcessorError
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const stripeSubscriptionItems = subscribableItems.map(
|
|
182
|
+
async (i): Promise<Stripe.SubscriptionCreateParams.Item> => {
|
|
183
|
+
const product = await this.stripe_.products.retrieve(
|
|
184
|
+
i.variant.product.metadata.stripe_product_id as string
|
|
185
|
+
);
|
|
186
|
+
const price = i.variant.prices.find(
|
|
187
|
+
(p) =>
|
|
188
|
+
p.currency.code.toLowerCase() ==
|
|
189
|
+
cart.region.currency_code.toLowerCase()
|
|
190
|
+
);
|
|
191
|
+
const interval = i.variant.metadata.subscription_interval_period
|
|
192
|
+
? (parseInt(
|
|
193
|
+
i.variant.metadata
|
|
194
|
+
.subscription_interval_period as string
|
|
195
|
+
) as number)
|
|
196
|
+
: this.options_.subscription_interval_period ?? 30;
|
|
197
|
+
|
|
198
|
+
const taxRateIds = await this.getOrCreateStripeTaxRates(
|
|
199
|
+
i,
|
|
200
|
+
cart.billing_address.country_code
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const item: Stripe.SubscriptionCreateParams.Item = {
|
|
204
|
+
quantity: 1,
|
|
205
|
+
price_data: {
|
|
206
|
+
currency: i.cart.region.currency_code.toLowerCase(),
|
|
207
|
+
product: i.variant.product.metadata
|
|
208
|
+
.stripe_product_id as string,
|
|
209
|
+
recurring: {
|
|
210
|
+
interval: i.variant.metadata
|
|
211
|
+
.subscription_interval as
|
|
212
|
+
| "day"
|
|
213
|
+
| "week"
|
|
214
|
+
| "month"
|
|
215
|
+
| "year",
|
|
216
|
+
|
|
217
|
+
interval_count: interval
|
|
218
|
+
},
|
|
219
|
+
tax_behavior: "exclusive",
|
|
220
|
+
unit_amount: price.amount,
|
|
221
|
+
unit_amount_decimal: (price.amount / 100).toFixed(2)
|
|
222
|
+
},
|
|
223
|
+
tax_rates: taxRateIds,
|
|
224
|
+
|
|
225
|
+
metadata: {
|
|
226
|
+
variant_id: i.variant_id,
|
|
227
|
+
region: i.cart.region.id
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
return item;
|
|
231
|
+
}
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const result = await Promise.all(stripeSubscriptionItems);
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async isSubscriptionCart(cartId: string): Promise<boolean> {
|
|
239
|
+
const cart = await this.cartService.retrieve(cartId, {
|
|
240
|
+
relations: ["items", "items.variant"]
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (cart.items.some((i) => i.variant.metadata.subscription != "true")) {
|
|
244
|
+
return false;
|
|
245
|
+
} else {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async initiatePayment(
|
|
251
|
+
context: PaymentProcessorContext
|
|
252
|
+
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse> {
|
|
253
|
+
const intentRequestData = this.getPaymentIntentOptions();
|
|
254
|
+
const {
|
|
255
|
+
email,
|
|
256
|
+
context: cart_context,
|
|
257
|
+
currency_code,
|
|
258
|
+
amount,
|
|
259
|
+
resource_id,
|
|
260
|
+
customer
|
|
261
|
+
} = context;
|
|
262
|
+
|
|
263
|
+
if (!(await this.isSubscriptionCart(resource_id))) {
|
|
264
|
+
return super.initiatePayment(context);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const description = (cart_context.payment_description ??
|
|
268
|
+
this.options_?.payment_description) as string;
|
|
269
|
+
|
|
270
|
+
const intentRequest: Stripe.PaymentIntentCreateParams = {
|
|
271
|
+
description,
|
|
272
|
+
amount: Math.round(amount),
|
|
273
|
+
currency: currency_code,
|
|
274
|
+
metadata: { resource_id },
|
|
275
|
+
capture_method: this.options_.capture ? "automatic" : "manual",
|
|
276
|
+
...intentRequestData
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (this.options_?.automatic_payment_methods) {
|
|
280
|
+
intentRequest.automatic_payment_methods = { enabled: true };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (customer?.metadata?.stripe_id) {
|
|
284
|
+
intentRequest.customer = customer.metadata.stripe_id as string;
|
|
285
|
+
} else {
|
|
286
|
+
let stripeCustomer;
|
|
287
|
+
try {
|
|
288
|
+
stripeCustomer = await this.stripe_.customers.create({
|
|
289
|
+
email
|
|
290
|
+
});
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return this.buildError(
|
|
293
|
+
"An error occurred in initiatePayment when creating a Stripe customer",
|
|
294
|
+
e
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
intentRequest.customer = stripeCustomer.id;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let session_data;
|
|
302
|
+
try {
|
|
303
|
+
const sub = await this.stripe_.subscriptions.retrieve(
|
|
304
|
+
session_data.id
|
|
305
|
+
);
|
|
306
|
+
const itemsExpected = await this.getStripeSubscriptionItemsFromCart(
|
|
307
|
+
resource_id
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if ((itemsExpected as PaymentProcessorError).error) {
|
|
311
|
+
return itemsExpected as PaymentProcessorError;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const items =
|
|
315
|
+
itemsExpected as Stripe.SubscriptionCreateParams.Item[];
|
|
316
|
+
const createSubscriptionParams: Stripe.SubscriptionCreateParams = {
|
|
317
|
+
items: items,
|
|
318
|
+
currency: currency_code,
|
|
319
|
+
metadata: { resource_id },
|
|
320
|
+
cancel_at_period_end: this.options.cancel_at_period_end,
|
|
321
|
+
|
|
322
|
+
customer: intentRequest.customer,
|
|
323
|
+
collection_method: "charge_automatically",
|
|
324
|
+
payment_behavior: "error_if_incomplete"
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
session_data = (await this.stripe_.subscriptions.create(
|
|
328
|
+
createSubscriptionParams
|
|
329
|
+
)) as unknown as Record<string, string>;
|
|
330
|
+
|
|
331
|
+
// session_data = (await this.stripe_.paymentIntents.create(
|
|
332
|
+
// intentRequest
|
|
333
|
+
// )) as unknown as Record<string, unknown>
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return this.buildError(
|
|
336
|
+
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
|
|
337
|
+
e
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
session_data,
|
|
343
|
+
update_requests: customer?.metadata?.stripe_id
|
|
344
|
+
? undefined
|
|
345
|
+
: {
|
|
346
|
+
customer_metadata: {
|
|
347
|
+
stripe_id: intentRequest.customer
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async authorizePayment(
|
|
354
|
+
paymentSessionData: Record<string, unknown>,
|
|
355
|
+
context: Record<string, unknown>
|
|
356
|
+
): Promise<
|
|
357
|
+
| PaymentProcessorError
|
|
358
|
+
| {
|
|
359
|
+
status: PaymentSessionStatus;
|
|
360
|
+
data: PaymentProcessorSessionResponse["session_data"];
|
|
361
|
+
}
|
|
362
|
+
> {
|
|
363
|
+
const status = await this.getPaymentStatus(paymentSessionData);
|
|
364
|
+
return { data: paymentSessionData, status };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async cancelPayment(
|
|
368
|
+
paymentSessionData: Record<string, unknown>
|
|
369
|
+
): Promise<
|
|
370
|
+
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
|
|
371
|
+
> {
|
|
372
|
+
try {
|
|
373
|
+
const id = paymentSessionData.id as string;
|
|
374
|
+
return (await this.stripe_.subscriptions.cancel(
|
|
375
|
+
id
|
|
376
|
+
)) as unknown as PaymentProcessorSessionResponse["session_data"];
|
|
377
|
+
} catch (error) {
|
|
378
|
+
super.cancelPayment(paymentSessionData);
|
|
379
|
+
// if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) {
|
|
380
|
+
// return error.payment_intent;
|
|
381
|
+
// }
|
|
382
|
+
|
|
383
|
+
// return this.buildError("An error occurred in cancelPayment", error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async capturePayment(
|
|
388
|
+
paymentSessionData: Record<string, unknown>
|
|
389
|
+
): Promise<
|
|
390
|
+
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
|
|
391
|
+
> {
|
|
392
|
+
const id = paymentSessionData.id as string;
|
|
393
|
+
try {
|
|
394
|
+
const intent = await this.stripe_.subscriptions.retrieve(id);
|
|
395
|
+
if (intent.status != "active") {
|
|
396
|
+
return this.buildError(
|
|
397
|
+
`Subscription not active. Payment is currently ${intent.status}`,
|
|
398
|
+
{} as PaymentProcessorError
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return intent as unknown as PaymentProcessorSessionResponse["session_data"];
|
|
402
|
+
} catch (error) {
|
|
403
|
+
return super.capturePayment(paymentSessionData);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async deletePayment(
|
|
408
|
+
paymentSessionData: Record<string, unknown>
|
|
409
|
+
): Promise<
|
|
410
|
+
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
|
|
411
|
+
> {
|
|
412
|
+
return await this.cancelPayment(paymentSessionData);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async retrievePayment(
|
|
416
|
+
paymentSessionData: Record<string, unknown>
|
|
417
|
+
): Promise<
|
|
418
|
+
PaymentProcessorError | PaymentProcessorSessionResponse["session_data"]
|
|
419
|
+
> {
|
|
420
|
+
try {
|
|
421
|
+
const id = paymentSessionData.id as string;
|
|
422
|
+
const intent = await this.stripe_.subscriptions.retrieve(id);
|
|
423
|
+
return intent as unknown as PaymentProcessorSessionResponse["session_data"];
|
|
424
|
+
} catch (e) {
|
|
425
|
+
return super.retrievePayment(paymentSessionData);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async updatePayment(
|
|
430
|
+
context: PaymentProcessorContext
|
|
431
|
+
): Promise<PaymentProcessorError | PaymentProcessorSessionResponse | void> {
|
|
432
|
+
const { amount, customer, paymentSessionData, resource_id } = context;
|
|
433
|
+
const stripeId = customer?.metadata?.stripe_id;
|
|
434
|
+
const subscriptionData =
|
|
435
|
+
paymentSessionData as unknown as Stripe.Subscription;
|
|
436
|
+
|
|
437
|
+
if (stripeId !== paymentSessionData.customer) {
|
|
438
|
+
const result = await this.initiatePayment(context);
|
|
439
|
+
if (isPaymentProcessorError(result)) {
|
|
440
|
+
return this.buildError(
|
|
441
|
+
"An error occurred in updatePayment during the initiate of the new payment for the new customer",
|
|
442
|
+
result
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return result;
|
|
447
|
+
} else {
|
|
448
|
+
if (!(await this.isSubscriptionCart(resource_id))) {
|
|
449
|
+
return super.updatePayment(context);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (amount && paymentSessionData.amount === Math.round(amount)) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
const id = subscriptionData.id as string;
|
|
458
|
+
const itemsExpected =
|
|
459
|
+
await this.getStripeSubscriptionItemsFromCart(resource_id);
|
|
460
|
+
|
|
461
|
+
if ((itemsExpected as PaymentProcessorError).error) {
|
|
462
|
+
return itemsExpected as PaymentProcessorError;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const items =
|
|
466
|
+
itemsExpected as Stripe.SubscriptionCreateParams.Item[];
|
|
467
|
+
|
|
468
|
+
const subscriptionUpdateParams: Stripe.SubscriptionUpdateParams =
|
|
469
|
+
{
|
|
470
|
+
items: items,
|
|
471
|
+
metadata: { resource_id },
|
|
472
|
+
cancel_at_period_end: this.options.cancel_at_period_end,
|
|
473
|
+
collection_method: "charge_automatically",
|
|
474
|
+
payment_behavior: "error_if_incomplete"
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const sessionData = (await this.stripe_.subscriptions.update(
|
|
478
|
+
id,
|
|
479
|
+
subscriptionUpdateParams
|
|
480
|
+
)) as unknown as PaymentProcessorSessionResponse["session_data"];
|
|
481
|
+
|
|
482
|
+
return { session_data: sessionData };
|
|
483
|
+
} catch (e) {
|
|
484
|
+
return this.buildError("An error occurred in updatePayment", e);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async updatePaymentData(
|
|
490
|
+
sessionId: string,
|
|
491
|
+
data: Record<string, unknown>
|
|
492
|
+
): Promise<Record<string, unknown> | PaymentProcessorError> {
|
|
493
|
+
const subscriptionParams: Stripe.SubscriptionUpdateParams = data;
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const result = (await this.stripe_.subscriptions.retrieve(
|
|
497
|
+
sessionId,
|
|
498
|
+
{ expand: ["metadata"] }
|
|
499
|
+
)) as unknown as Stripe.Subscription;
|
|
500
|
+
if (!result.metadata.resource_id) {
|
|
501
|
+
return super.updatePaymentData(sessionId, data);
|
|
502
|
+
}
|
|
503
|
+
} catch (e) {
|
|
504
|
+
return this.buildError(
|
|
505
|
+
"Subscription not associated with cart, cannot update",
|
|
506
|
+
e
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
// Prevent from updating the amount from here as it should go through
|
|
511
|
+
// the updatePayment method to perform the correct logic
|
|
512
|
+
|
|
513
|
+
const result = (await this.stripe_.subscriptions.update(sessionId, {
|
|
514
|
+
...subscriptionParams
|
|
515
|
+
})) as unknown as PaymentProcessorSessionResponse["session_data"];
|
|
516
|
+
return result;
|
|
517
|
+
} catch (e) {
|
|
518
|
+
return this.buildError("An error occurred in updatePaymentData", e);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export default StripeSubscriptionService;
|
package/src/index.ts
ADDED
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Custom scheduled jobs
|
|
2
|
+
|
|
3
|
+
You may define custom scheduled jobs (cron jobs) by creating files in the `/jobs` directory.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
ProductService,
|
|
8
|
+
ScheduledJobArgs,
|
|
9
|
+
ScheduledJobConfig,
|
|
10
|
+
} from "@medusajs/medusa";
|
|
11
|
+
|
|
12
|
+
export default async function myCustomJob({ container }: ScheduledJobArgs) {
|
|
13
|
+
const productService: ProductService = container.resolve("productService");
|
|
14
|
+
|
|
15
|
+
const products = await productService.listAndCount();
|
|
16
|
+
|
|
17
|
+
// Do something with the products
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const config: ScheduledJobConfig = {
|
|
21
|
+
name: "daily-product-report",
|
|
22
|
+
schedule: "0 0 * * *", // Every day at midnight
|
|
23
|
+
};
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A scheduled job is defined in two parts a `handler` and a `config`. The `handler` is a function which is invoked when the job is scheduled. The `config` is an object which defines the name of the job, the schedule, and an optional data object.
|
|
27
|
+
|
|
28
|
+
The `handler` is a function which takes one parameter, an `object` of type `ScheduledJobArgs` with the following properties:
|
|
29
|
+
|
|
30
|
+
- `container` - a `MedusaContainer` instance which can be used to resolve services.
|
|
31
|
+
- `data` - an `object` containing data passed to the job when it was scheduled. This object is passed in the `config` object.
|
|
32
|
+
- `pluginOptions` - an `object` containing plugin options, if the job is defined in a plugin.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Custom loader
|
|
2
|
+
|
|
3
|
+
The loader allows you have access to the Medusa service container. This allows you to access the database and the services registered on the container.
|
|
4
|
+
you can register custom registrations in the container or run custom code on startup.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// src/loaders/my-loader.ts
|
|
8
|
+
|
|
9
|
+
import { AwilixContainer } from 'awilix'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
*
|
|
13
|
+
* @param container The container in which the registrations are made
|
|
14
|
+
* @param config The options of the plugin or the entire config object
|
|
15
|
+
*/
|
|
16
|
+
export default (container: AwilixContainer, config: Record<string, unknown>): void | Promise<void> => {
|
|
17
|
+
/* Implement your own loader. */
|
|
18
|
+
}
|
|
19
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Custom migrations
|
|
2
|
+
|
|
3
|
+
You may define custom models (entities) that will be registered on the global container by creating files in the `src/models` directory that export an instance of `BaseEntity`.
|
|
4
|
+
In that case you also need to provide a migration in order to create the table in the database.
|
|
5
|
+
|
|
6
|
+
## Example
|
|
7
|
+
|
|
8
|
+
### 1. Create the migration
|
|
9
|
+
|
|
10
|
+
See [How to Create Migrations](https://docs.medusajs.com/advanced/backend/migrations/) in the documentation.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// src/migration/my-migration.ts
|
|
14
|
+
|
|
15
|
+
import { MigrationInterface, QueryRunner } from "typeorm"
|
|
16
|
+
|
|
17
|
+
export class MyMigration1617703530229 implements MigrationInterface {
|
|
18
|
+
name = "myMigration1617703530229"
|
|
19
|
+
|
|
20
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
21
|
+
// write you migration here
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
25
|
+
// write you migration here
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
```
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Custom models
|
|
2
|
+
|
|
3
|
+
You may define custom models (entities) that will be registered on the global container by creating files in the `src/models` directory that export an instance of `BaseEntity`.
|
|
4
|
+
|
|
5
|
+
## Example
|
|
6
|
+
|
|
7
|
+
### 1. Create the Entity
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// src/models/post.ts
|
|
11
|
+
|
|
12
|
+
import { BeforeInsert, Column, Entity, PrimaryColumn } from "typeorm";
|
|
13
|
+
import { generateEntityId } from "@medusajs/utils";
|
|
14
|
+
import { BaseEntity } from "@medusajs/medusa";
|
|
15
|
+
|
|
16
|
+
@Entity()
|
|
17
|
+
export class Post extends BaseEntity {
|
|
18
|
+
@Column({type: 'varchar'})
|
|
19
|
+
title: string | null;
|
|
20
|
+
|
|
21
|
+
@BeforeInsert()
|
|
22
|
+
private beforeInsert(): void {
|
|
23
|
+
this.id = generateEntityId(this.id, "post")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Create the Migration
|
|
29
|
+
|
|
30
|
+
You also need to create a Migration to create the new table in the database. See [How to Create Migrations](https://docs.medusajs.com/advanced/backend/migrations/) in the documentation.
|
|
31
|
+
|
|
32
|
+
### 3. Create a Repository
|
|
33
|
+
Entities data can be easily accessed and modified using [TypeORM Repositories](https://typeorm.io/working-with-repository). To create a repository, create a file in `src/repositories`. For example, here’s a repository `PostRepository` for the `Post` entity:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// src/repositories/post.ts
|
|
37
|
+
|
|
38
|
+
import { EntityRepository, Repository } from "typeorm"
|
|
39
|
+
|
|
40
|
+
import { Post } from "../models/post"
|
|
41
|
+
|
|
42
|
+
@EntityRepository(Post)
|
|
43
|
+
export class PostRepository extends Repository<Post> { }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See more about defining and accesing your custom [Entities](https://docs.medusajs.com/advanced/backend/entities/overview) in the documentation.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Custom services
|
|
2
|
+
|
|
3
|
+
You may define custom services that will be registered on the global container by creating files in the `/services` directory that export an instance of `BaseService`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// src/services/my-custom.ts
|
|
7
|
+
|
|
8
|
+
import { Lifetime } from "awilix"
|
|
9
|
+
import { TransactionBaseService } from "@medusajs/medusa";
|
|
10
|
+
import { IEventBusService } from "@medusajs/types";
|
|
11
|
+
|
|
12
|
+
export default class MyCustomService extends TransactionBaseService {
|
|
13
|
+
static LIFE_TIME = Lifetime.SCOPED
|
|
14
|
+
protected readonly eventBusService_: IEventBusService
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
{ eventBusService }: { eventBusService: IEventBusService },
|
|
18
|
+
options: Record<string, unknown>
|
|
19
|
+
) {
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
super(...arguments)
|
|
22
|
+
|
|
23
|
+
this.eventBusService_ = eventBusService
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The first argument to the `constructor` is the global giving you access to easy dependency injection. The container holds all registered services from the core, installed plugins and from other files in the `/services` directory. The registration name is a camelCased version of the file name with the type appended i.e.: `my-custom.js` is registered as `myCustomService`, `custom-thing.js` is registered as `customThingService`.
|
|
30
|
+
|
|
31
|
+
You may use the services you define here in custom endpoints by resolving the services defined.
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { Router } from "express"
|
|
35
|
+
|
|
36
|
+
export default () => {
|
|
37
|
+
const router = Router()
|
|
38
|
+
|
|
39
|
+
router.get("/hello-product", async (req, res) => {
|
|
40
|
+
const myService = req.scope.resolve("myCustomService")
|
|
41
|
+
|
|
42
|
+
res.json({
|
|
43
|
+
message: await myService.getProductMessage()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return router;
|
|
48
|
+
}
|
|
49
|
+
```
|