@dineway-ai/plugin-payment 0.2.3 → 0.2.4

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.
@@ -0,0 +1,185 @@
1
+ import type Stripe from "stripe";
2
+
3
+ import {
4
+ SubscriptionError,
5
+ type MemberBilling,
6
+ type MemberSubscription,
7
+ type PaidSubscriptionPeriod,
8
+ type SubscriptionPlan,
9
+ type SubscriptionPrice,
10
+ type SubscriptionScope,
11
+ } from "./types.js";
12
+
13
+ export function recurringPrice(
14
+ price: Stripe.Price,
15
+ scope: SubscriptionScope,
16
+ requireActive = true,
17
+ ): SubscriptionPrice {
18
+ const interval = price.recurring?.interval;
19
+ const productId = typeof price.product === "string" ? price.product : price.product.id;
20
+ if (
21
+ price.livemode !== (scope.mode === "live") ||
22
+ (requireActive && !price.active) ||
23
+ price.type !== "recurring" ||
24
+ !price.recurring ||
25
+ !["month", "year"].includes(interval ?? "") ||
26
+ price.recurring.interval_count !== 1 ||
27
+ price.recurring.usage_type !== "licensed" ||
28
+ price.billing_scheme !== "per_unit" ||
29
+ price.transform_quantity ||
30
+ price.unit_amount === null ||
31
+ !Number.isSafeInteger(price.unit_amount) ||
32
+ price.unit_amount <= 0 ||
33
+ (interval !== "month" && interval !== "year")
34
+ ) {
35
+ throw new SubscriptionError(
36
+ "INVALID_PRICE",
37
+ "Use an active fixed monthly or yearly recurring price",
38
+ );
39
+ }
40
+ return {
41
+ id: price.id,
42
+ productId,
43
+ currency: price.currency,
44
+ amount: price.unit_amount,
45
+ interval,
46
+ enabled: price.active,
47
+ };
48
+ }
49
+
50
+ export function ownedSubscription(
51
+ subscription: Stripe.Subscription,
52
+ scope: SubscriptionScope,
53
+ userId: string,
54
+ customerId: string,
55
+ ) {
56
+ const customer =
57
+ typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;
58
+ if (
59
+ subscription.livemode !== (scope.mode === "live") ||
60
+ customer !== customerId ||
61
+ subscription.metadata.dineway_app !== scope.namespace ||
62
+ subscription.metadata.user_id !== userId
63
+ ) {
64
+ throw new SubscriptionError("OWNER_CONFLICT", "Subscription ownership conflict", 409);
65
+ }
66
+ }
67
+
68
+ export function subscriptionProjection(
69
+ subscription: Stripe.Subscription,
70
+ plans: SubscriptionPlan[],
71
+ scope: SubscriptionScope,
72
+ userId: string,
73
+ customerId: string,
74
+ now: number,
75
+ ): MemberSubscription {
76
+ ownedSubscription(subscription, scope, userId, customerId);
77
+ const item = subscription.items.data[0];
78
+ const plan = plans.find((value) => value.prices.some((price) => price.id === item?.price.id));
79
+ if (
80
+ !plan ||
81
+ subscription.items.has_more ||
82
+ subscription.items.data.length !== 1 ||
83
+ item?.quantity !== 1
84
+ ) {
85
+ throw new SubscriptionError("UNRECOGNIZED_SUBSCRIPTION", "Subscription requires review", 409);
86
+ }
87
+ recurringPrice(item.price, scope, false);
88
+ return {
89
+ id: subscription.id,
90
+ userId,
91
+ customerId,
92
+ planCode: plan.code,
93
+ priceId: item.price.id,
94
+ status: subscription.status,
95
+ periodEnd: item.current_period_end,
96
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
97
+ verifiedAt: now,
98
+ lastError: null,
99
+ };
100
+ }
101
+
102
+ export function invoicePeriods(
103
+ invoice: Stripe.Invoice,
104
+ lines: Stripe.InvoiceLineItem[],
105
+ subscription: MemberSubscription,
106
+ plans: SubscriptionPlan[],
107
+ scope: SubscriptionScope,
108
+ ): PaidSubscriptionPeriod[] {
109
+ const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
110
+ const relation = invoice.parent?.subscription_details?.subscription;
111
+ const subscriptionId = typeof relation === "string" ? relation : relation?.id;
112
+ if (
113
+ invoice.status !== "paid" ||
114
+ invoice.livemode !== (scope.mode === "live") ||
115
+ customerId !== subscription.customerId ||
116
+ subscriptionId !== subscription.id
117
+ ) {
118
+ throw new SubscriptionError(
119
+ "INVALID_INVOICE",
120
+ "Invoice does not belong to this subscription",
121
+ 409,
122
+ );
123
+ }
124
+ return lines.flatMap((line) => {
125
+ const details = line.parent?.subscription_item_details;
126
+ const priceId = line.pricing?.price_details?.price;
127
+ const id = typeof priceId === "string" ? priceId : priceId?.id;
128
+ const plan = plans.find((value) => value.prices.some((price) => price.id === id));
129
+ if (
130
+ !details ||
131
+ details.subscription !== subscription.id ||
132
+ details.proration ||
133
+ !plan ||
134
+ line.quantity !== 1 ||
135
+ line.amount <= 0
136
+ )
137
+ return [];
138
+ if (
139
+ !Number.isSafeInteger(line.period.start) ||
140
+ !Number.isSafeInteger(line.period.end) ||
141
+ line.period.end <= line.period.start
142
+ )
143
+ throw new SubscriptionError("INVALID_INVOICE", "Invalid service period");
144
+ return [
145
+ {
146
+ id: invoice.id + ":" + line.id,
147
+ invoiceId: invoice.id,
148
+ subscriptionId: subscription.id,
149
+ planCode: plan.code,
150
+ startsAt: line.period.start,
151
+ endsAt: line.period.end,
152
+ },
153
+ ];
154
+ });
155
+ }
156
+
157
+ export function memberProjection(
158
+ customer: boolean,
159
+ subscriptions: MemberSubscription[],
160
+ periods: PaidSubscriptionPeriod[],
161
+ plans: SubscriptionPlan[],
162
+ now: number,
163
+ ): MemberBilling {
164
+ const valid = periods.filter(
165
+ (period) => period.startsAt * 1000 <= now && period.endsAt * 1000 > now,
166
+ );
167
+ const entitlements: Record<string, boolean | number> = {};
168
+ for (const period of valid) {
169
+ const plan = plans.find((value) => value.code === period.planCode);
170
+ for (const [key, value] of Object.entries(plan?.entitlements ?? {})) {
171
+ const previous = entitlements[key];
172
+ if (previous !== undefined && typeof previous !== typeof value) continue;
173
+ entitlements[key] =
174
+ typeof value === "boolean"
175
+ ? previous === true || value
176
+ : Math.max(typeof previous === "number" ? previous : 0, value);
177
+ }
178
+ }
179
+ return {
180
+ customer,
181
+ subscriptions,
182
+ paidThrough: valid.length ? Math.max(...valid.map((period) => period.endsAt)) : null,
183
+ entitlements,
184
+ };
185
+ }
@@ -0,0 +1,443 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { Database } from "dineway";
4
+ import type { Kysely, Transaction } from "kysely";
5
+
6
+ import { entitlementSchema } from "./schemas.js";
7
+ import {
8
+ SubscriptionError,
9
+ type MemberSubscription,
10
+ type PaidSubscriptionPeriod,
11
+ type SaveSubscriptionPlan,
12
+ type SubscriptionPlan,
13
+ type SubscriptionPrice,
14
+ type SubscriptionScope,
15
+ } from "./types.js";
16
+
17
+ type Db = Kysely<Database>;
18
+ type Trx = Transaction<Database>;
19
+ type SubscriptionRow = Database["payment_member_subscriptions"];
20
+ const scopedId = ["scope", "id"] as const;
21
+ const accountPattern = /^acct_[A-Za-z0-9]+$/;
22
+ const namespacePattern = /^[a-z][a-z0-9_-]{0,63}$/;
23
+ const keyColumns = () => [...scopedId];
24
+
25
+ export function subscriptionScopeKey(scope: SubscriptionScope): string {
26
+ if (
27
+ !accountPattern.test(scope.accountId) ||
28
+ !namespacePattern.test(scope.namespace) ||
29
+ !["test", "live"].includes(scope.mode)
30
+ ) {
31
+ throw new SubscriptionError("INVALID_SCOPE", "Invalid subscription scope");
32
+ }
33
+ return [scope.accountId, scope.mode, scope.namespace].join(":");
34
+ }
35
+
36
+ export class SubscriptionRepository {
37
+ readonly scope: string;
38
+ constructor(
39
+ readonly db: Db,
40
+ scope: SubscriptionScope,
41
+ readonly now: () => number = Date.now,
42
+ private readonly lease?: { id: string; token: string },
43
+ ) {
44
+ this.scope = subscriptionScopeKey(scope);
45
+ this.scopeDetails = scope;
46
+ }
47
+ private readonly scopeDetails: SubscriptionScope;
48
+
49
+ async plans(): Promise<SubscriptionPlan[]> {
50
+ const [plans, prices] = await Promise.all([
51
+ this.db
52
+ .selectFrom("payment_subscription_plans")
53
+ .selectAll()
54
+ .where("scope", "=", this.scope)
55
+ .orderBy("id")
56
+ .execute(),
57
+ this.db
58
+ .selectFrom("payment_subscription_prices")
59
+ .selectAll()
60
+ .where("scope", "=", this.scope)
61
+ .orderBy("id")
62
+ .execute(),
63
+ ]);
64
+ return plans.map((plan) => ({
65
+ code: plan.id,
66
+ name: plan.name,
67
+ enabled: plan.enabled === 1,
68
+ version: plan.version,
69
+ entitlements: entitlementSchema.parse(JSON.parse(plan.entitlements)),
70
+ prices: prices
71
+ .filter((price) => price.plan_code === plan.id)
72
+ .map((price) => {
73
+ if (price.interval !== "month" && price.interval !== "year")
74
+ throw new SubscriptionError("INVALID_PRICE", "Invalid saved price", 503);
75
+ return {
76
+ id: price.id,
77
+ productId: price.product_id,
78
+ currency: price.currency,
79
+ amount: price.amount,
80
+ interval: price.interval,
81
+ enabled: price.enabled === 1,
82
+ };
83
+ }),
84
+ }));
85
+ }
86
+
87
+ async savePlan(input: SaveSubscriptionPlan, prices: SubscriptionPrice[]): Promise<void> {
88
+ await this.db.transaction().execute(async (trx) => {
89
+ const values = {
90
+ name: input.name,
91
+ enabled: Number(input.enabled),
92
+ entitlements: JSON.stringify(input.entitlements),
93
+ version: input.expectedVersion + 1,
94
+ };
95
+ if (input.expectedVersion === 0) {
96
+ const inserted = await trx
97
+ .insertInto("payment_subscription_plans")
98
+ .values({ scope: this.scope, id: input.code, ...values })
99
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
100
+ .returning("id")
101
+ .executeTakeFirst();
102
+ if (!inserted)
103
+ throw new SubscriptionError("CONFLICT", "Plan changed; reload and try again", 409);
104
+ } else {
105
+ const updated = await trx
106
+ .updateTable("payment_subscription_plans")
107
+ .set(values)
108
+ .where("scope", "=", this.scope)
109
+ .where("id", "=", input.code)
110
+ .where("version", "=", input.expectedVersion)
111
+ .returning("id")
112
+ .executeTakeFirst();
113
+ if (!updated)
114
+ throw new SubscriptionError("CONFLICT", "Plan changed; reload and try again", 409);
115
+ }
116
+ await trx
117
+ .updateTable("payment_subscription_prices")
118
+ .set({ enabled: 0 })
119
+ .where("scope", "=", this.scope)
120
+ .where("plan_code", "=", input.code)
121
+ .execute();
122
+ for (const price of prices) {
123
+ const previous = await trx
124
+ .selectFrom("payment_subscription_prices")
125
+ .select("plan_code")
126
+ .where("scope", "=", this.scope)
127
+ .where("id", "=", price.id)
128
+ .executeTakeFirst();
129
+ if (previous && previous.plan_code !== input.code)
130
+ throw new SubscriptionError("PRICE_IN_USE", "A price belongs to another plan", 409);
131
+ await trx
132
+ .insertInto("payment_subscription_prices")
133
+ .values({
134
+ scope: this.scope,
135
+ id: price.id,
136
+ plan_code: input.code,
137
+ product_id: price.productId,
138
+ currency: price.currency,
139
+ amount: price.amount,
140
+ interval: price.interval,
141
+ enabled: Number(price.enabled),
142
+ })
143
+ .onConflict((conflict) =>
144
+ conflict.columns(keyColumns()).doUpdateSet({ enabled: Number(price.enabled) }),
145
+ )
146
+ .execute();
147
+ }
148
+ });
149
+ }
150
+
151
+ async customer(userId: string): Promise<string | null> {
152
+ const row = await this.db
153
+ .selectFrom("payment_subscription_customers")
154
+ .select("customer_id")
155
+ .where("scope", "=", this.scope)
156
+ .where("id", "=", userId)
157
+ .executeTakeFirst();
158
+ return row?.customer_id ?? null;
159
+ }
160
+
161
+ async customerOwner(customerId: string): Promise<string | null> {
162
+ const row = await this.db
163
+ .selectFrom("payment_subscription_customers")
164
+ .select("id")
165
+ .where("scope", "=", this.scope)
166
+ .where("customer_id", "=", customerId)
167
+ .executeTakeFirst();
168
+ return row?.id ?? null;
169
+ }
170
+
171
+ async saveCustomer(userId: string, customerId: string): Promise<void> {
172
+ await this.db.transaction().execute(async (trx) => {
173
+ await this.assertLease(trx);
174
+ await trx
175
+ .insertInto("payment_subscription_customers")
176
+ .values({ scope: this.scope, id: userId, customer_id: customerId })
177
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
178
+ .execute();
179
+ const saved = await trx
180
+ .selectFrom("payment_subscription_customers")
181
+ .select("customer_id")
182
+ .where("scope", "=", this.scope)
183
+ .where("id", "=", userId)
184
+ .executeTakeFirstOrThrow();
185
+ if (saved.customer_id !== customerId)
186
+ throw new SubscriptionError("OWNER_CONFLICT", "Customer ownership conflict", 409);
187
+ });
188
+ }
189
+
190
+ async subscriptions(userId: string): Promise<MemberSubscription[]> {
191
+ const rows = await this.db
192
+ .selectFrom("payment_member_subscriptions")
193
+ .selectAll()
194
+ .where("scope", "=", this.scope)
195
+ .where("user_id", "=", userId)
196
+ .orderBy("id")
197
+ .execute();
198
+ return rows.map(projectSubscription);
199
+ }
200
+
201
+ async subscription(id: string): Promise<MemberSubscription | null> {
202
+ const row = await this.db
203
+ .selectFrom("payment_member_subscriptions")
204
+ .selectAll()
205
+ .where("scope", "=", this.scope)
206
+ .where("id", "=", id)
207
+ .executeTakeFirst();
208
+ return row ? projectSubscription(row) : null;
209
+ }
210
+
211
+ async listSubscriptions(
212
+ cursor?: string,
213
+ limit = 50,
214
+ ): Promise<{ items: MemberSubscription[]; nextCursor?: string }> {
215
+ let query = this.db
216
+ .selectFrom("payment_member_subscriptions")
217
+ .selectAll()
218
+ .where("scope", "=", this.scope);
219
+ if (cursor) query = query.where("id", ">", cursor);
220
+ const rows = await query
221
+ .orderBy("id")
222
+ .limit(limit + 1)
223
+ .execute();
224
+ const items = rows.slice(0, limit).map(projectSubscription);
225
+ return { items, ...(rows.length > limit ? { nextCursor: items.at(-1)?.id } : {}) };
226
+ }
227
+
228
+ async periods(userId: string): Promise<PaidSubscriptionPeriod[]> {
229
+ const rows = await this.db
230
+ .selectFrom("payment_subscription_periods as periods")
231
+ .innerJoin("payment_member_subscriptions as subscriptions", (join) =>
232
+ join
233
+ .onRef("subscriptions.scope", "=", "periods.scope")
234
+ .onRef("subscriptions.id", "=", "periods.subscription_id"),
235
+ )
236
+ .selectAll("periods")
237
+ .where("periods.scope", "=", this.scope)
238
+ .where("subscriptions.user_id", "=", userId)
239
+ .orderBy("periods.ends_at", "desc")
240
+ .execute();
241
+ return rows.map((row) => ({
242
+ id: row.id,
243
+ invoiceId: row.invoice_id,
244
+ subscriptionId: row.subscription_id,
245
+ planCode: row.plan_code,
246
+ startsAt: row.starts_at,
247
+ endsAt: row.ends_at,
248
+ }));
249
+ }
250
+
251
+ async saveSubscription(
252
+ state: MemberSubscription,
253
+ periods: PaidSubscriptionPeriod[],
254
+ eventId?: string,
255
+ ): Promise<void> {
256
+ await this.db.transaction().execute(async (trx) => {
257
+ await this.assertLease(trx);
258
+ const owner = await trx
259
+ .selectFrom("payment_subscription_customers")
260
+ .select("id")
261
+ .where("scope", "=", this.scope)
262
+ .where("customer_id", "=", state.customerId)
263
+ .executeTakeFirst();
264
+ const existing = await trx
265
+ .selectFrom("payment_member_subscriptions")
266
+ .select(["user_id", "customer_id"])
267
+ .where("scope", "=", this.scope)
268
+ .where("id", "=", state.id)
269
+ .executeTakeFirst();
270
+ if (
271
+ owner?.id !== state.userId ||
272
+ (existing &&
273
+ (existing.user_id !== state.userId || existing.customer_id !== state.customerId))
274
+ ) {
275
+ throw new SubscriptionError("OWNER_CONFLICT", "Subscription ownership conflict", 409);
276
+ }
277
+ const values = {
278
+ user_id: state.userId,
279
+ customer_id: state.customerId,
280
+ plan_code: state.planCode,
281
+ price_id: state.priceId,
282
+ status: state.status,
283
+ period_end: state.periodEnd,
284
+ cancel_at_period_end: Number(state.cancelAtPeriodEnd),
285
+ verified_at: state.verifiedAt,
286
+ last_error: state.lastError,
287
+ };
288
+ await trx
289
+ .insertInto("payment_member_subscriptions")
290
+ .values({ scope: this.scope, id: state.id, ...values })
291
+ .onConflict((conflict) => conflict.columns(keyColumns()).doUpdateSet(values))
292
+ .execute();
293
+ for (const period of periods) {
294
+ await trx
295
+ .insertInto("payment_subscription_periods")
296
+ .values({
297
+ scope: this.scope,
298
+ id: period.id,
299
+ invoice_id: period.invoiceId,
300
+ subscription_id: state.id,
301
+ plan_code: period.planCode,
302
+ starts_at: period.startsAt,
303
+ ends_at: period.endsAt,
304
+ })
305
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
306
+ .execute();
307
+ }
308
+ if (eventId)
309
+ await trx
310
+ .insertInto("payment_subscription_events")
311
+ .values({ scope: this.scope, id: eventId, received_at: this.now() })
312
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
313
+ .execute();
314
+ });
315
+ }
316
+
317
+ async recordSyncFailure(id: string, verifiedAt: number): Promise<void> {
318
+ await this.db
319
+ .updateTable("payment_member_subscriptions")
320
+ .set({ last_error: "SYNC_UNAVAILABLE" })
321
+ .where("scope", "=", this.scope)
322
+ .where("id", "=", id)
323
+ .where("verified_at", "=", verifiedAt)
324
+ .execute();
325
+ }
326
+ async eventSeen(id: string): Promise<boolean> {
327
+ return Boolean(
328
+ await this.db
329
+ .selectFrom("payment_subscription_events")
330
+ .select("id")
331
+ .where("scope", "=", this.scope)
332
+ .where("id", "=", id)
333
+ .executeTakeFirst(),
334
+ );
335
+ }
336
+ async portalConfiguration(): Promise<string | null> {
337
+ const row = await this.db
338
+ .selectFrom("payment_subscription_portals")
339
+ .select("configuration_id")
340
+ .where("scope", "=", this.scope)
341
+ .where("id", "=", "default")
342
+ .executeTakeFirst();
343
+ return row?.configuration_id ?? null;
344
+ }
345
+ async savePortalConfiguration(configurationId: string): Promise<void> {
346
+ await this.db.transaction().execute(async (trx) => {
347
+ await this.assertLease(trx);
348
+ await trx
349
+ .insertInto("payment_subscription_portals")
350
+ .values({ scope: this.scope, id: "default", configuration_id: configurationId })
351
+ .onConflict((conflict) => conflict.columns(keyColumns()).doNothing())
352
+ .execute();
353
+ });
354
+ }
355
+ async attempt(userId: string) {
356
+ return this.db
357
+ .selectFrom("payment_subscription_attempts")
358
+ .selectAll()
359
+ .where("scope", "=", this.scope)
360
+ .where("id", "=", userId)
361
+ .executeTakeFirst();
362
+ }
363
+ async saveAttempt(
364
+ userId: string,
365
+ attempt: {
366
+ fingerprint: string;
367
+ request_id: string;
368
+ session_id: string | null;
369
+ created_at: number;
370
+ },
371
+ ): Promise<void> {
372
+ await this.db.transaction().execute(async (trx) => {
373
+ await this.assertLease(trx);
374
+ await trx
375
+ .insertInto("payment_subscription_attempts")
376
+ .values({ scope: this.scope, id: userId, ...attempt })
377
+ .onConflict((conflict) => conflict.columns(keyColumns()).doUpdateSet(attempt))
378
+ .execute();
379
+ });
380
+ }
381
+
382
+ async withLock<T>(
383
+ id: string,
384
+ action: (repository: SubscriptionRepository) => Promise<T>,
385
+ ): Promise<T> {
386
+ const token = randomUUID();
387
+ const now = this.now();
388
+ const claimed = await this.db
389
+ .insertInto("payment_subscription_locks")
390
+ .values({ scope: this.scope, id, token, expires_at: now + 180000 })
391
+ .onConflict((conflict) =>
392
+ conflict
393
+ .columns(keyColumns())
394
+ .doUpdateSet({ token, expires_at: now + 180000 })
395
+ .where("payment_subscription_locks.expires_at", "<", now),
396
+ )
397
+ .returning("token")
398
+ .executeTakeFirst();
399
+ if (!claimed)
400
+ throw new SubscriptionError("BILLING_BUSY", "Billing is being updated; try again", 409);
401
+ try {
402
+ return await action(
403
+ new SubscriptionRepository(this.db, this.scopeDetails, this.now, { id, token }),
404
+ );
405
+ } finally {
406
+ await this.db
407
+ .deleteFrom("payment_subscription_locks")
408
+ .where("scope", "=", this.scope)
409
+ .where("id", "=", id)
410
+ .where("token", "=", token)
411
+ .execute();
412
+ }
413
+ }
414
+
415
+ private async assertLease(trx: Trx): Promise<void> {
416
+ if (!this.lease)
417
+ throw new SubscriptionError("LOCK_REQUIRED", "A billing lock is required", 409);
418
+ const row = await trx
419
+ .selectFrom("payment_subscription_locks")
420
+ .select("token")
421
+ .where("scope", "=", this.scope)
422
+ .where("id", "=", this.lease.id)
423
+ .where("token", "=", this.lease.token)
424
+ .where("expires_at", ">", this.now())
425
+ .executeTakeFirst();
426
+ if (!row) throw new SubscriptionError("BILLING_BUSY", "Billing lease expired; try again", 409);
427
+ }
428
+ }
429
+
430
+ function projectSubscription(row: SubscriptionRow): MemberSubscription {
431
+ return {
432
+ id: row.id,
433
+ userId: row.user_id,
434
+ customerId: row.customer_id,
435
+ planCode: row.plan_code,
436
+ priceId: row.price_id,
437
+ status: row.status,
438
+ periodEnd: row.period_end,
439
+ cancelAtPeriodEnd: row.cancel_at_period_end === 1,
440
+ verifiedAt: row.verified_at,
441
+ lastError: row.last_error,
442
+ };
443
+ }