Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117877635
D2239.1775338511.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
29 KB
Referenced Files
None
Subscribers
None
D2239.1775338511.diff
View Options
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use App\Wallet;
+use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
@@ -55,6 +56,7 @@
$mandate = [
'currency' => 'CHF',
'description' => \config('app.name') . ' Auto-Payment Setup',
+ 'methodId' => $request->methodId
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
@@ -219,6 +221,7 @@
'type' => PaymentProvider::TYPE_ONEOFF,
'currency' => 'CHF',
'amount' => $amount,
+ 'methodId' => $request->methodId,
'description' => \config('app.name') . ' Payment',
];
@@ -231,6 +234,32 @@
return response()->json($result);
}
+ /**
+ * Delete a pending payment.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function cancel(Request $request)
+ {
+ $current_user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets()->first();
+
+ $paymentId = $request->payment;
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->cancel($wallet, $paymentId);
+
+ //TODO handle errors?
+ $result['status'] = 'success';
+
+ return response()->json($result);
+ }
+
/**
* Update payment status (and balance).
*
@@ -325,4 +354,80 @@
return $mandate;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function paymentMethods(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $methods = $provider->paymentMethods($request->type);
+ return response()->json($methods);
+ }
+
+
+ /**
+ * List pending payments.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public static function payments(Request $request)
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $provider = PaymentProvider::factory($wallet);
+ $pageSize = 10;
+ $page = intval(request()->input('page')) ?: 1;
+ $hasMore = false;
+ $result = Payment::where('wallet_id', $wallet->id)
+ ->where('type', 'oneoff')
+ ->whereIn('status', ['open', 'pending', 'authorized'])
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ $result = $result->map(function ($item) use ($provider) {
+ $payment = $provider->getPayment($item->id);
+ $entry = [
+ 'id' => $item->id,
+ 'createdAt' => $item->created_at->format('Y-m-d H:i'),
+ 'type' => $item->type,
+ 'description' => $item->description,
+ 'amount' => $item->amount,
+ 'status' => $item->status,
+ 'isCancelable' => $payment['isCancelable'],
+ 'checkoutUrl' => $payment['checkoutUrl']
+ ];
+
+ return $entry;
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ 'page' => $page,
+ ]);
+ }
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -66,7 +66,7 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => Utils::serviceUrl('/wallet'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId']
];
// Create the payment in Mollie
@@ -135,12 +135,36 @@
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
- 'method' => self::paymentMethod($mandate, 'Unknown method')
+ 'method' => self::paymentMethod($mandate, 'Unknown method'),
+ 'methodId' => $mandate->method
];
return $result;
}
+ /**
+ * Get a payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ public function getPayment($paymentId): ?array
+ {
+ $payment = mollie()->payments()->get($paymentId);
+
+ return [
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'isCancelable' => $payment->isCancelable,
+ 'checkoutUrl' => $payment->getCheckoutUrl()
+ ];
+ }
+
/**
* Get a provider name
*
@@ -187,7 +211,7 @@
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
- // 'method' => 'creditcard',
+ 'method' => $payment['methodId'],
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
@@ -210,6 +234,34 @@
];
}
+
+ /**
+ * Cancel a pending payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $paymentId Payment Id
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function cancel(Wallet $wallet, $paymentId): ?array
+ {
+
+ $response = mollie()->payments()->delete($paymentId);
+
+ $payment['id'] = $response->id;
+ $payment['status'] = $response->status;
+
+ $this->storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+
/**
* Create a new automatic payment operation.
*
@@ -510,4 +562,38 @@
return $default;
}
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ */
+ public function paymentMethods($type): ?array
+ {
+
+ $result = mollie()->methods()->allActive(
+ [
+ 'sequenceType' => $type,
+ 'amount' => [
+ 'value' => '4.00',
+ 'currency' => 'CHF'
+ ]
+ ]
+ );
+ $availableMethods = [];
+ foreach ($result as $method) {
+ $availableMethods[] = [
+ 'id' => $method->id,
+ 'name' => $method->description,
+ 'minimumAmount' => $method->minimumAmount->value,
+ 'currency' => $method->minimumAmount->currency,
+ 'image' => $method->image->svg
+ ];
+ }
+ return $availableMethods;
+ }
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -470,4 +470,43 @@
return $default;
}
+
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ */
+ public function paymentMethods($type): ?array
+ {
+ //TODO get this from the mollie API?
+ switch ($type) {
+ case self::TYPE_ONEOFF:
+ return [
+ [
+ 'id' => 'creditcard',
+ 'name' => "Credit Card"
+ ],
+ [
+ 'id' => 'paypal',
+ 'name' => "PayPal"
+ ]
+ ];
+ case self::TYPE_RECURRING:
+ return [
+ [
+ 'id' => 'creditcard',
+ 'name' => "Credit Card"
+ ],
+ [
+ 'id' => 'paypal',
+ 'name' => "PayPal"
+ ]
+ ];
+ }
+ }
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -194,4 +194,16 @@
$this->storePayment($refund, $wallet->id);
}
+
+
+ /**
+ * List supported payment methods.
+ *
+ * @param string $type type: oneoff/recurring
+ *
+ * @return array Array of array with available payment methods:
+ * - id: id of the method
+ * - name: User readable name of the payment method
+ */
+ abstract public function paymentMethods($type): ?array;
}
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -293,6 +293,27 @@
}
}
+#payment-method-selection {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ & > button {
+ padding: 1rem;
+ text-align: center;
+ white-space: nowrap;
+ margin: 0.25rem;
+ text-decoration: none;
+ width: 150px;
+ }
+
+ img {
+ width: 6rem;
+ height: 6rem;
+ margin: auto;
+ }
+}
+
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -5,8 +5,48 @@
<div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
- <p>Add credit to your account or setup an automatic payment by using the button below.</p>
- <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+
+ <div v-if="showPendingPayments" class="alert alert-warning">
+ You have a payments that are still in progress. See the payments tab below.
+ </div>
+ <p>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('manual')">Add credit</button>
+ </p>
+ <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
+ <template v-if="mandate.id && !mandate.isValid">
+ <div class="alert alert-danger">
+ The setup of automatic payments failed. Restart the process to enable automatic top-ups.
+ </div>
+ <p>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </p>
+ </template>
+ <button type="button" class="btn btn-primary" @click="paymentMethodForm('auto')">Set up auto-payment</button>
+ </div>
+ <div id="mandate-info" v-else>
+ <div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
+ The configured auto-payment has been disabled. Top up your wallet or
+ raise the auto-payment amount.
+ </div>
+ <template v-else>
+ <p>
+ Auto-payment is <b>enabled</b> to add <b>{{ mandate.amount }} CHF</b>
+ to your account whenever your balance runs below <b>{{ mandate.balance }} CHF</b>.
+ </p>
+ <p>
+ Method of payment: {{ mandate.method }}
+ </p>
+ </template>
+ <div v-if="mandate.isPending" class="alert alert-warning">
+ The setup of the automatic payment is still in progress.
+ </div>
+ <p>
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ </p>
+ <p>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </p>
+ </div>
</div>
</div>
</div>
@@ -22,6 +62,11 @@
History
</a>
</li>
+ <li v-if="showPendingPayments" class="nav-item">
+ <a class="nav-link" id="tab-payments" href="#wallet-payments" role="tab" aria-controls="wallet-payments" aria-selected="false">
+ Pending Payments
+ </a>
+ </li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
@@ -53,6 +98,11 @@
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
+ <div class="tab-pane show" id="wallet-payments" role="tabpanel" aria-labelledby="tab-payments">
+ <div class="card-body">
+ <payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
+ </div>
+ </div>
</div>
<div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
@@ -65,7 +115,16 @@
</button>
</div>
<div class="modal-body">
- <div id="payment" v-if="paymentForm == 'init'">
+ <div id="payment-method" v-if="paymentForm == 'method'">
+ <form data-validation-prefix="mandate_">
+ <div id="payment-method-selection">
+ <button v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method.id)" class="card link-profile">
+ <img v-bind:src="method.image" /><span class="name">{{ method.name }}</span>
+ </button>
+ </div>
+ </form>
+ </div>
+ <div id="manual-payment" v-if="paymentForm == 'manual'">
<p>Choose the amount by which you want to top up your wallet.</p>
<form id="payment-form" @submit.prevent="payment">
<div class="form-group">
@@ -82,35 +141,6 @@
</button>
</div>
</form>
- <div class="form-separator"><hr><span>or</span></div>
- <div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending">
- <p>Add auto-payment, so you never run out.</p>
- <div v-if="mandate.id && !mandate.isValid" class="alert alert-danger">
- The setup of automatic payments failed. Restart the process to enable automatic top-ups.
- </div>
- <div class="w-100 text-center">
- <button type="button" class="btn btn-primary" @click="autoPaymentForm">Set up auto-payment</button>
- </div>
- </div>
- <div id="mandate-info" v-else>
- <p>
- Auto-payment is set to fill up your account by <b>{{ mandate.amount }} CHF</b>
- every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
- You will be charged via {{ mandate.method }}.
- </p>
- <div v-if="mandate.isPending" class="alert alert-warning">
- The setup of the automatic payment is still in progress.
- </div>
- <div v-else-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
- The configured auto-payment has been disabled. Top up your wallet or
- raise the auto-payment amount.
- </div>
- <p>You can cancel or change the auto-payment at any time.</p>
- <div class="form-group d-flex justify-content-around">
- <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
- <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
- </div>
- </div>
</div>
<div id="auto-payment" v-if="paymentForm == 'auto'">
<form data-validation-prefix="mandate_">
@@ -173,22 +203,28 @@
<script>
import TransactionLog from './Widgets/TransactionLog'
+ import PaymentLog from './Widgets/PaymentLog'
export default {
components: {
- TransactionLog
+ TransactionLog,
+ PaymentLog
},
data() {
return {
amount: '',
- mandate: { amount: 10, balance: 0 },
+ mandate: { amount: 10, balance: 0, method: null, methodId: null },
paymentDialogTitle: null,
- paymentForm: 'init',
+ paymentForm: null,
+ nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
+ loadPayments: false,
+ showPendingPayments: false,
wallet: {},
- walletId: null
+ walletId: null,
+ paymentMethods: []
}
},
mounted() {
@@ -220,17 +256,28 @@
})
.catch(this.$root.errorHandler)
+ this.loadMandate()
+
+ axios.get('/api/v4/payments/pending')
+ .then(response => {
+ this.showPendingPayments = (response.data.list.length > 0)
+ })
+
+ },
+ updated() {
$(this.$el).find('ul.nav-tabs a').on('click', e => {
e.preventDefault()
$(e.target).tab('show')
if ($(e.target).is('#tab-history')) {
this.loadTransactions = true
}
+ if ($(e.target).is('#tab-payments')) {
+ this.loadPayments = true
+ }
})
},
methods: {
- paymentDialog() {
- const dialog = $('#payment-dialog')
+ loadMandate() {
const mandate_form = $('#mandate-form')
this.$root.removeLoader(mandate_form)
@@ -246,14 +293,14 @@
this.$root.removeLoader(mandate_form)
})
}
-
+ },
+ selectPaymentMethod(method) {
+ this.mandate.methodId = method
+ this.paymentForm = this.nextForm
+ this.paymentDialogTitle = 'Choose Amount'
this.formLock = false
- this.paymentForm = 'init'
- this.paymentDialogTitle = 'Top up your wallet'
- this.dialog = dialog.on('shown.bs.modal', () => {
- dialog.find('#amount').focus()
- }).modal()
+ setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
},
payment() {
if (this.formLock) {
@@ -266,7 +313,7 @@
this.$root.clearFormValidation($('#payment-form'))
- axios.post('/api/v4/payments', {amount: this.amount}, { onFinish })
+ axios.post('/api/v4/payments', {amount: this.amount, methodId: this.mandate.methodId}, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
@@ -287,7 +334,8 @@
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
const post = {
amount: this.mandate.amount,
- balance: this.mandate.balance
+ balance: this.mandate.balance,
+ methodId: this.mandate.methodId
}
this.$root.clearFormValidation($('#auto-payment form'))
@@ -324,10 +372,40 @@
}
})
},
+
+ paymentMethodForm(nextForm) {
+ const dialog = $('#payment-dialog')
+ this.formLock = false
+ this.paymentMethods = []
+
+ this.paymentForm = 'method'
+ this.paymentDialogTitle = 'Payment Method'
+ this.nextForm = nextForm
+
+ const methods = $('#payment-method')
+ this.$root.addLoader(methods)
+ axios.get('/api/v4/payments/methods', {params: {type: nextForm == 'manual' ? 'oneoff' : 'recurring'}})
+ .then(response => {
+ this.$root.removeLoader(methods)
+ this.paymentMethods = response.data
+ })
+ .catch(this.$root.errorHandler)
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#mandate_amount').focus()
+ }).modal()
+ },
autoPaymentForm(event, title) {
+ const dialog = $('#payment-dialog')
+
this.paymentForm = 'auto'
this.paymentDialogTitle = title || 'Add auto-payment'
this.formLock = false
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#mandate_amount').focus()
+ }).modal()
+
setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
},
receiptDownload() {
diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/PaymentLog.vue
@@ -0,0 +1,95 @@
+<template>
+ <div>
+ <table class="table table-sm m-0 payments">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Date</th>
+ <th scope="col">Description</th>
+ <th scope="col" class="price">Amount</th>
+ <th scope="col">Status</th>
+ <th scope="col"></th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id">
+ <td class="datetime">{{ payment.createdAt }}</td>
+ <td class="description">{{ payment.description }}</td>
+ <td :class="'price ' + text-success">{{ amount(payment) }}</td>
+ <td>{{ payment.status }}</td>
+ <td><a v-if="payment.checkoutUrl" v-bind:href="payment.checkoutUrl">Details</a></td>
+ <td><button class="btn btn-lg btn-link btn-action cancel" title="Cancel Payment" v-if="payment.isCancelable" @click="cancel(payment)">Cancel Payment</button></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td :colspan="4">There are no payments for this account.</td>
+ </tr>
+ </tfoot>
+ </table>
+ <div class="text-center p-3" id="payments-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadLog(true)">Load more</button>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ },
+ data() {
+ return {
+ payments: [],
+ hasMore: false,
+ page: 1
+ }
+ },
+ mounted() {
+ this.loadLog()
+ },
+ methods: {
+ loadLog(more) {
+ let loader = $(this.$el)
+ let param = ''
+
+ if (more) {
+ param = '?page=' + (this.page + 1)
+ loader = $('#payments-loader')
+ }
+
+ this.$root.addLoader(loader)
+ axios.get('/api/v4/payments/pending' + param)
+ .then(response => {
+ this.$root.removeLoader(loader)
+ // Note: In Vue we can't just use .concat()
+ for (let i in response.data.list) {
+ this.$set(this.payments, this.payments.length, response.data.list[i])
+ }
+ this.hasMore = response.data.hasMore
+ this.page = response.data.page || 1
+ })
+ .catch(error => {
+ this.$root.removeLoader(loader)
+ })
+ },
+ amount(payment) {
+ return this.$root.price(payment.amount)
+ },
+ cancel(payment) {
+ let record = $('#log' + payment.id)
+ let cell = record.find('td > button.cancel')
+
+ this.$root.addLoader(cell)
+
+ axios.delete('/api/v4/payments' + '?payment=' + payment.id)
+ .then(response => {
+ this.$root.removeLoader(cell)
+ record.remove()
+ })
+ .catch(error => {
+ this.$root.removeLoader(cell)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -76,10 +76,13 @@
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
+ Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
+ Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
+ Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 9:35 PM (18 h, 9 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831261
Default Alt Text
D2239.1775338511.diff (29 KB)
Attached To
Mode
D2239: New Payment dialog structure
Attached
Detach File
Event Timeline