@owlmeans/server-payment 0.1.18-rc.20 → 0.1.18-rc.21
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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +20 -2
- package/tests/fake-stripe.ts +188 -18
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import {
|
|
2
|
+
billingLanguageOf, chargeCurrencyOf, ConsumerRegion, ConsumerRightsError, ENTITLING_STATUSES, inScope, PurchaseKind,
|
|
3
|
+
regionOf, withdrawalOpen,
|
|
4
|
+
} from '@owlmeans/payment'
|
|
5
|
+
import type { BillingProfileView, ConsumerRightsPolicy, PurchaseView } from '@owlmeans/payment'
|
|
6
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
7
|
+
import { PURCHASE_ID_PREFIX, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
8
|
+
import {
|
|
9
|
+
billingProfiles, compact, conditionalDelete, consumerEvents, fulfillments, isDuplicateKey, paygateCustomers, purchases,
|
|
10
|
+
stripePricingConfig, subscriptions,
|
|
11
|
+
} from '../utils.js'
|
|
12
|
+
import { makeContractRef, normalizeEmail } from './format.js'
|
|
13
|
+
import type {
|
|
14
|
+
BillingProfileRecord, BillingProfileSource, ConsumerEventRecord, LockOptions, PaymentSubscriptionRecord,
|
|
15
|
+
PurchaseRecord, PurchaseRef, UnlockOptions,
|
|
16
|
+
} from '../types.js'
|
|
17
|
+
|
|
18
|
+
export const purchaseIdOf = (externalId: string): string => `${PURCHASE_ID_PREFIX}:${externalId}`
|
|
19
|
+
|
|
20
|
+
/** A locked profile as the wire sees it. */
|
|
21
|
+
export const profileViewOf = (record: BillingProfileRecord, policy: ConsumerRightsPolicy | null): BillingProfileView => ({
|
|
22
|
+
country: record.country,
|
|
23
|
+
region: record.region,
|
|
24
|
+
currency: record.currency,
|
|
25
|
+
locked: true,
|
|
26
|
+
lockedAt: new Date(record.lockedAt),
|
|
27
|
+
language: record.language,
|
|
28
|
+
inScope: inScope(record.region, record.country, policy),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** No profile yet: nothing locked, the default legal language, an unknown buyer (protected by default). */
|
|
32
|
+
export const unlockedProfileView = (policy: ConsumerRightsPolicy | null): BillingProfileView => ({
|
|
33
|
+
country: null,
|
|
34
|
+
region: null,
|
|
35
|
+
currency: null,
|
|
36
|
+
locked: false,
|
|
37
|
+
language: policy?.defaultLanguage ?? 'en',
|
|
38
|
+
inScope: inScope(null, null, policy),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append one audit step. A failure to write it is logged and never fails the act it describes —
|
|
43
|
+
* the act's own record (consent, declaration, purchase) is already written.
|
|
44
|
+
*/
|
|
45
|
+
export const recordEvent = async (
|
|
46
|
+
ctx: ApiContext, event: Omit<ConsumerEventRecord, 'at' | 'id'> & { at?: Date },
|
|
47
|
+
): Promise<void> => {
|
|
48
|
+
try {
|
|
49
|
+
await consumerEvents(ctx).create(compact({ ...event, at: event.at ?? new Date() }) as ConsumerEventRecord)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`[payment] consumer event "${event.action}" of "${event.recordId}" not recorded`, error)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a step already succeeded (or a mail was deliberately skipped) for a record. */
|
|
56
|
+
export const hasEvent = async (
|
|
57
|
+
ctx: ApiContext, recordId: string, action: ConsumerEventRecord['action'], step?: string, ok?: boolean,
|
|
58
|
+
): Promise<boolean> => await consumerEvents(ctx).load(compact({
|
|
59
|
+
recordId, action, ...(step != null ? { step } : {}), ...(ok != null ? { ok } : {}),
|
|
60
|
+
})) != null
|
|
61
|
+
|
|
62
|
+
/** The currency of the entity's entitling Stripe subscription — two of one customer cannot differ. */
|
|
63
|
+
export const activeSubscriptionCurrency = async (ctx: ApiContext, entityId: string): Promise<string | undefined> => {
|
|
64
|
+
const row = await entitlingStripeSubscription(ctx, entityId)
|
|
65
|
+
|
|
66
|
+
return row?.currency ?? undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The entity's highest-ranked entitling Stripe subscription, or `null`. */
|
|
70
|
+
export const entitlingStripeSubscription = async (
|
|
71
|
+
ctx: ApiContext, entityId: string,
|
|
72
|
+
): Promise<PaymentSubscriptionRecord | null> => await subscriptions(ctx).load(
|
|
73
|
+
{ entityId, paygate: STRIPE_PAYGATE_ALIAS, status: [...ENTITLING_STATUSES] },
|
|
74
|
+
{ sort: [{ field: 'rank', order: 'desc' }, { field: 'createdAt', order: 'desc' }] },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
/** Whether the entity ever paid through the paygate (a fulfilled checkout or a propagated subscription). */
|
|
78
|
+
export const hasPaid = async (ctx: ApiContext, entityId: string): Promise<boolean> =>
|
|
79
|
+
await fulfillments(ctx).load({ entityId, paygate: STRIPE_PAYGATE_ALIAS, fulfilledAt: { $exists: true } }) != null
|
|
80
|
+
|| await subscriptions(ctx).load({ entityId, paygate: STRIPE_PAYGATE_ALIAS, initialPropagatedAt: { $exists: true } }) != null
|
|
81
|
+
|
|
82
|
+
export interface LockInput extends LockOptions {
|
|
83
|
+
entityId: string
|
|
84
|
+
country: string
|
|
85
|
+
source: BillingProfileSource
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LockResult {
|
|
89
|
+
record: BillingProfileRecord
|
|
90
|
+
created: boolean
|
|
91
|
+
/** The lock exists with another country: recorded as `lock-mismatch`, never relocked. */
|
|
92
|
+
mismatch: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fix an entity's billing country — the FIRST write wins (a unique index on `entityId` settles a
|
|
97
|
+
* race). A later, different country is only a `lock-mismatch` event; only a `manual` lock with
|
|
98
|
+
* `force` replaces the country, audited as `relock`. The charge currency is fixed with it: the
|
|
99
|
+
* currency of an entitling paygate subscription when one exists (a customer's subscriptions share
|
|
100
|
+
* one currency), else the region's (`policy.currencies`), else the given one.
|
|
101
|
+
*/
|
|
102
|
+
export const lockProfile = async (
|
|
103
|
+
ctx: ApiContext, policy: ConsumerRightsPolicy | null, input: LockInput,
|
|
104
|
+
): Promise<LockResult> => {
|
|
105
|
+
const country = input.country.trim().toUpperCase()
|
|
106
|
+
if (!/^[A-Z]{2}$/.test(country)) {
|
|
107
|
+
throw new ConsumerRightsError(`lock:country:${input.country}`)
|
|
108
|
+
}
|
|
109
|
+
const resource = billingProfiles(ctx)
|
|
110
|
+
const now = new Date()
|
|
111
|
+
const existing = await resource.byEntity(input.entityId)
|
|
112
|
+
if (existing != null) {
|
|
113
|
+
return await compareLock(ctx, policy, existing, input, country, now)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const region = regionOf(country, policy) ?? ConsumerRegion.Other
|
|
117
|
+
const settlement = (await stripePricingConfig(ctx))?.settlementCurrency?.toLowerCase()
|
|
118
|
+
const currency = (await activeSubscriptionCurrency(ctx, input.entityId))
|
|
119
|
+
?? (policy?.currencies != null && Object.keys(policy.currencies).length > 0
|
|
120
|
+
? chargeCurrencyOf(region, policy, input.currency ?? settlement ?? 'usd')
|
|
121
|
+
: input.currency ?? settlement ?? 'usd')
|
|
122
|
+
const record = compact({
|
|
123
|
+
entityId: input.entityId,
|
|
124
|
+
country,
|
|
125
|
+
region,
|
|
126
|
+
currency: currency.toLowerCase(),
|
|
127
|
+
language: input.language ?? billingLanguageOf(country, policy),
|
|
128
|
+
source: input.source,
|
|
129
|
+
paygate: STRIPE_PAYGATE_ALIAS,
|
|
130
|
+
customerId: input.customerId,
|
|
131
|
+
sessionId: input.sessionId,
|
|
132
|
+
ipCountry: input.ipCountry?.toUpperCase(),
|
|
133
|
+
email: input.email,
|
|
134
|
+
name: input.name,
|
|
135
|
+
business: input.business,
|
|
136
|
+
lockedAt: now,
|
|
137
|
+
createdAt: now,
|
|
138
|
+
}) as BillingProfileRecord
|
|
139
|
+
try {
|
|
140
|
+
const created = await resource.create(record)
|
|
141
|
+
await recordEvent(ctx, {
|
|
142
|
+
recordId: input.entityId, recordKind: 'profile', entityId: input.entityId, action: 'lock', ok: true,
|
|
143
|
+
detail: JSON.stringify(compact({
|
|
144
|
+
source: input.source, country, currency: created.currency, sessionId: input.sessionId,
|
|
145
|
+
ipCountry: input.ipCountry?.toUpperCase(),
|
|
146
|
+
ipMismatch: input.ipCountry != null ? input.ipCountry.toUpperCase() !== country : undefined,
|
|
147
|
+
by: input.by, reason: input.reason,
|
|
148
|
+
})),
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return { record: created, created: true, mismatch: false }
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!isDuplicateKey(error)) {
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
const winner = await resource.byEntity(input.entityId)
|
|
157
|
+
if (winner == null) {
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return await compareLock(ctx, policy, winner, input, country, now)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const compareLock = async (
|
|
166
|
+
ctx: ApiContext, policy: ConsumerRightsPolicy | null, existing: BillingProfileRecord, input: LockInput,
|
|
167
|
+
country: string, now: Date,
|
|
168
|
+
): Promise<LockResult> => {
|
|
169
|
+
if (input.source === 'manual' && input.force === true) {
|
|
170
|
+
const region = regionOf(country, policy) ?? ConsumerRegion.Other
|
|
171
|
+
const updated = await billingProfiles(ctx).update(compact({
|
|
172
|
+
...existing,
|
|
173
|
+
country,
|
|
174
|
+
region,
|
|
175
|
+
currency: (input.currency ?? (country === existing.country ? existing.currency
|
|
176
|
+
: chargeCurrencyOf(region, policy, existing.currency))).toLowerCase(),
|
|
177
|
+
language: input.language ?? (country === existing.country ? existing.language : billingLanguageOf(country, policy)),
|
|
178
|
+
source: 'manual' as const,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
}))
|
|
181
|
+
await recordEvent(ctx, {
|
|
182
|
+
recordId: existing.entityId, recordKind: 'profile', entityId: existing.entityId, action: 'relock', ok: true,
|
|
183
|
+
detail: JSON.stringify(compact({
|
|
184
|
+
from: existing.country, to: country, currency: updated.currency, by: input.by, reason: input.reason,
|
|
185
|
+
})),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return { record: updated, created: false, mismatch: false }
|
|
189
|
+
}
|
|
190
|
+
const mismatch = existing.country !== country
|
|
191
|
+
if (mismatch) {
|
|
192
|
+
await recordEvent(ctx, {
|
|
193
|
+
recordId: existing.entityId, recordKind: 'profile', entityId: existing.entityId, action: 'lock-mismatch',
|
|
194
|
+
ok: true,
|
|
195
|
+
detail: JSON.stringify(compact({
|
|
196
|
+
locked: existing.country, observed: country, source: input.source, sessionId: input.sessionId,
|
|
197
|
+
ipCountry: input.ipCountry,
|
|
198
|
+
})),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { record: existing, created: false, mismatch }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* An operator removes an entity's lock: the profile row is deleted (only while it still holds the
|
|
207
|
+
* country read — a concurrent relock survives) and an `unlock` event keeps the whole row, `by` and
|
|
208
|
+
* `reason`. `null` when nothing was locked.
|
|
209
|
+
*/
|
|
210
|
+
export const unlockProfile = async (
|
|
211
|
+
ctx: ApiContext, entityId: string, opts: UnlockOptions = {},
|
|
212
|
+
): Promise<BillingProfileRecord | null> => {
|
|
213
|
+
const resource = billingProfiles(ctx)
|
|
214
|
+
const existing = await resource.byEntity(entityId)
|
|
215
|
+
if (existing == null) {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
if (!await conditionalDelete(resource, { entityId, country: existing.country })) {
|
|
219
|
+
throw new ConsumerRightsError(`unlock:changed:${entityId}`)
|
|
220
|
+
}
|
|
221
|
+
const { id: _id, ...row } = existing
|
|
222
|
+
await recordEvent(ctx, {
|
|
223
|
+
recordId: entityId, recordKind: 'profile', entityId, action: 'unlock', ok: true,
|
|
224
|
+
detail: JSON.stringify(compact({ from: existing.country, by: opts.by, reason: opts.reason, profile: row })),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
return existing
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether an operator unlocked the entity — then no lock is taken from its paygate customer's saved
|
|
232
|
+
* address (at checkout or in reconcile): its next completed purchase locks it.
|
|
233
|
+
*/
|
|
234
|
+
export const wasUnlocked = async (ctx: ApiContext, entityId: string): Promise<boolean> =>
|
|
235
|
+
await hasEvent(ctx, entityId, 'unlock')
|
|
236
|
+
|
|
237
|
+
/** Inside its window at `at`: in scope, before the deadline, neither withdrawn from nor refunded. */
|
|
238
|
+
export const windowOpen = (purchase: PurchaseRecord, at: Date = new Date()): boolean =>
|
|
239
|
+
purchase.inScope && withdrawalOpen({
|
|
240
|
+
deadline: purchase.deadline != null ? new Date(purchase.deadline) : null,
|
|
241
|
+
withdrawnAt: purchase.withdrawnAt ?? null,
|
|
242
|
+
refundedAt: purchase.refundedAt ?? null,
|
|
243
|
+
}, at)
|
|
244
|
+
|
|
245
|
+
export const purchaseViewOf = (purchase: PurchaseRecord, withdrawable: boolean): PurchaseView => compact({
|
|
246
|
+
purchaseId: purchase.purchaseId,
|
|
247
|
+
contractRef: purchase.contractRef,
|
|
248
|
+
kind: purchase.kind,
|
|
249
|
+
purchasedAt: new Date(purchase.purchasedAt),
|
|
250
|
+
deadline: purchase.deadline != null ? new Date(purchase.deadline) : undefined,
|
|
251
|
+
productSku: purchase.productSku,
|
|
252
|
+
planSku: purchase.planSku ?? undefined,
|
|
253
|
+
amountTotalMinor: purchase.amountTotalMinor,
|
|
254
|
+
currency: purchase.currency,
|
|
255
|
+
consentedAt: purchase.consentedAt != null ? new Date(purchase.consentedAt) : undefined,
|
|
256
|
+
withdrawnAt: purchase.withdrawnAt != null ? new Date(purchase.withdrawnAt) : undefined,
|
|
257
|
+
withdrawable,
|
|
258
|
+
}) as PurchaseView
|
|
259
|
+
|
|
260
|
+
export const purchaseRefOf = (purchase: PurchaseRecord): PurchaseRef => compact({
|
|
261
|
+
purchaseId: purchase.purchaseId,
|
|
262
|
+
kind: purchase.kind,
|
|
263
|
+
entityId: purchase.entityId,
|
|
264
|
+
contractRef: purchase.contractRef,
|
|
265
|
+
productSku: purchase.productSku,
|
|
266
|
+
planSku: purchase.planSku ?? undefined,
|
|
267
|
+
sessionId: purchase.sessionId ?? undefined,
|
|
268
|
+
subscriptionId: purchase.subscriptionId ?? undefined,
|
|
269
|
+
invoiceId: purchase.invoiceId ?? undefined,
|
|
270
|
+
purchasedAt: new Date(purchase.purchasedAt),
|
|
271
|
+
}) as PurchaseRef
|
|
272
|
+
|
|
273
|
+
/** The open, unconsented top-up windows of an entity — what a performance consent covers. */
|
|
274
|
+
export const unconsentedWindows = async (ctx: ApiContext, entityId: string, at: Date = new Date()): Promise<PurchaseRecord[]> =>
|
|
275
|
+
(await purchases(ctx).list({
|
|
276
|
+
entityId, inScope: true, kind: PurchaseKind.TopUp, consentedAt: null, withdrawnAt: null, refundedAt: null,
|
|
277
|
+
deadline: { $gt: at },
|
|
278
|
+
}, { size: 100, sort: [{ field: 'deadline', order: 'desc' }] })).items
|
|
279
|
+
|
|
280
|
+
const CONTRACT_REF_ATTEMPTS = 6
|
|
281
|
+
|
|
282
|
+
export type PurchaseDraft = Omit<PurchaseRecord, 'id' | 'contractRef' | 'createdAt'>
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Write a purchase once: an existing row for the same purchase (or the same checkout session) is
|
|
286
|
+
* returned as it is. A contract-reference collision retries with a fresh reference.
|
|
287
|
+
*/
|
|
288
|
+
export const createPurchase = async (
|
|
289
|
+
ctx: ApiContext, draft: PurchaseDraft,
|
|
290
|
+
): Promise<{ record: PurchaseRecord, created: boolean }> => {
|
|
291
|
+
const resource = purchases(ctx)
|
|
292
|
+
const existing = await resource.byPurchaseId(draft.purchaseId)
|
|
293
|
+
?? (draft.sessionId != null ? await resource.load({ sessionId: draft.sessionId }) : null)
|
|
294
|
+
if (existing != null) {
|
|
295
|
+
return { record: existing, created: false }
|
|
296
|
+
}
|
|
297
|
+
let last: unknown = null
|
|
298
|
+
for (let attempt = 0; attempt < CONTRACT_REF_ATTEMPTS; attempt++) {
|
|
299
|
+
try {
|
|
300
|
+
const record = await resource.create(compact({
|
|
301
|
+
...draft, contractRef: makeContractRef(new Date(draft.purchasedAt)), createdAt: new Date(),
|
|
302
|
+
}) as PurchaseRecord)
|
|
303
|
+
|
|
304
|
+
return { record, created: true }
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (!isDuplicateKey(error)) {
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
last = error
|
|
310
|
+
const winner = await resource.byPurchaseId(draft.purchaseId)
|
|
311
|
+
?? (draft.sessionId != null ? await resource.load({ sessionId: draft.sessionId }) : null)
|
|
312
|
+
if (winner != null) {
|
|
313
|
+
return { record: winner, created: false }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
console.error('[payment] no free contract reference after retries', last)
|
|
318
|
+
throw new ConsumerRightsError('contract-ref')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Set fields on a purchase without replacing it (a concurrent conditional write survives). */
|
|
322
|
+
export const patchPurchase = async (
|
|
323
|
+
ctx: ApiContext, purchaseId: string, fields: Partial<PurchaseRecord>,
|
|
324
|
+
): Promise<void> => {
|
|
325
|
+
const set = Object.fromEntries(Object.entries({ ...fields, updatedAt: new Date() }).filter(([, value]) => value !== undefined))
|
|
326
|
+
const resource = purchases(ctx)
|
|
327
|
+
const collection = (resource as unknown as { collection?: { updateOne?: unknown } }).collection
|
|
328
|
+
if (collection?.updateOne != null) {
|
|
329
|
+
await (collection.updateOne as (filter: object, update: object) => Promise<unknown>)({ purchaseId }, { $set: set })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
const current = await resource.byPurchaseId(purchaseId)
|
|
333
|
+
if (current != null) {
|
|
334
|
+
await resource.update({ ...current, ...set })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The e-mail a consumer-rights mail reaches the organization at when no person named one. */
|
|
339
|
+
export const contactEmailOf = async (ctx: ApiContext, entityId: string): Promise<string | undefined> => {
|
|
340
|
+
const profile = await billingProfiles(ctx).byEntity(entityId)
|
|
341
|
+
if (profile?.email != null && profile.email !== '') {
|
|
342
|
+
return profile.email
|
|
343
|
+
}
|
|
344
|
+
const latest = await purchases(ctx).load({ entityId, email: { $exists: true } }, {
|
|
345
|
+
sort: [{ field: 'purchasedAt', order: 'desc' }],
|
|
346
|
+
})
|
|
347
|
+
if (latest?.email != null && latest.email !== '') {
|
|
348
|
+
return latest.email
|
|
349
|
+
}
|
|
350
|
+
const customer = await paygateCustomers(ctx).byEntity(entityId, STRIPE_PAYGATE_ALIAS)
|
|
351
|
+
|
|
352
|
+
return customer?.email ?? undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Whether an e-mail address belongs to a purchase: its checkout e-mail, the organization's
|
|
357
|
+
* profile e-mail or its paygate customer's — compared case-insensitively.
|
|
358
|
+
*/
|
|
359
|
+
export const emailMatches = async (ctx: ApiContext, purchase: PurchaseRecord, email: string): Promise<boolean> => {
|
|
360
|
+
const wanted = normalizeEmail(email)
|
|
361
|
+
if (wanted === '') {
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
if (normalizeEmail(purchase.email) === wanted) {
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
const profile = await billingProfiles(ctx).byEntity(purchase.entityId)
|
|
368
|
+
if (normalizeEmail(profile?.email) === wanted) {
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
const customer = await paygateCustomers(ctx).byEntity(purchase.entityId, STRIPE_PAYGATE_ALIAS)
|
|
372
|
+
|
|
373
|
+
return normalizeEmail(customer?.email) === wanted
|
|
374
|
+
}
|